mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 17:58:23 +00:00
Compare commits
12 Commits
codex/data
...
nathaniel/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfb1be400f | ||
|
|
a154e6a311 | ||
|
|
46fec1d47d | ||
|
|
1052658a02 | ||
|
|
f524683f3c | ||
|
|
d1d55585f9 | ||
|
|
98c654df20 | ||
|
|
5bce9c4874 | ||
|
|
6ac9b653bb | ||
|
|
86bccf8d4c | ||
|
|
b52f6ce764 | ||
|
|
5da3e16f33 |
730
GIT_BRANCHING_STRATEGY.md
Normal file
730
GIT_BRANCHING_STRATEGY.md
Normal file
@@ -0,0 +1,730 @@
|
||||
# Git Branching and Release Strategy
|
||||
|
||||
Status: Proposed
|
||||
Scope: ComfyUI_frontend branching, release management, and environment promotion
|
||||
Audience: frontend engineers, release rotation, QA, cloud and core release stakeholders
|
||||
|
||||
This document does three things:
|
||||
|
||||
1. Maps the current branching and release process and its measured pain points.
|
||||
2. Reviews a draft proposal to replace it with a 4-tier environment-branch model
|
||||
(testing, dev, staging, main), including an honest scorecard and a
|
||||
keep-or-reject disposition for every element of that proposal.
|
||||
3. Specifies the recommended strategy, how it addresses each pain point, and a
|
||||
phased rollout plan with risks and open questions.
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
The current process is not "only main". The repo runs one eternal development
|
||||
branch plus 55 frozen release branches (38 core lines, core/1.6 through
|
||||
core/1.47 with gaps; 17 cloud lines, cloud/1.31 through cloud/1.47), a
|
||||
label-driven cherry-pick backport pipeline,
|
||||
and a biweekly promotion train into ComfyUI core. The pain is real but it is
|
||||
not caused by a missing environment hierarchy. It is caused by the distance
|
||||
between main and the shipped lines: the longer a pinned release line lives,
|
||||
the more cherry-picks it needs, the harder each one gets, and the bigger and
|
||||
riskier each release batch becomes.
|
||||
|
||||
The draft 4-tier proposal is reviewed in section 4. Verdict: do not adopt as
|
||||
written (overall 4/10 for this repo), because it models a single-track web
|
||||
application while this product permanently ships three concurrent version
|
||||
tracks, and its central promise (eliminating backports) is unachievable while
|
||||
ComfyUI core pins an exact frontend package version. Eight of its underlying
|
||||
instincts are correct and are adopted into the recommendation.
|
||||
|
||||
The recommended strategy (section 6) is: one eternal branch (main), short-lived
|
||||
version branches only where a pinned version demands them (core/x.y), pipeline
|
||||
promotion of build-once artifacts for cloud environments instead of environment
|
||||
branches, hard freeze-as-code and drift limits, invariant checks instead of
|
||||
notification bots, and a machine-maintained production pointer that gives git
|
||||
visibility into what is deployed without human merge ceremony. This direction
|
||||
aligns with and extends the org's existing shipping-speed initiative rather
|
||||
than relitigating it.
|
||||
|
||||
## 2. Current topology: three concurrent tracks
|
||||
|
||||
Any branching strategy for this repo must first model what actually ships.
|
||||
There is no single production. Three tracks run concurrently, each with its own
|
||||
consumers, artifact, and patch path:
|
||||
|
||||
| Track | Consumers | Artifact | Deploys via | Patch path today |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Nightly | Community users running `--front-end-version @latest` (thousands) | GitHub release built from main | Nightly version-bump PR, tag, release | Fix merges to main, ships next nightly |
|
||||
| Cloud | cloud.comfy.org users | Static assets built per commit SHA into GCS by the cloud repo | testcloud tracks the active `cloud/x.y` tip; staging and prod promote a SHA pointer via overlays and ArgoCD | Cherry-pick to `cloud/x.y` via backport label, then staged deploy |
|
||||
| Core GA / desktop | ComfyUI stable and desktop installs | `comfyui-frontend-package` wheel on PyPI, pinned exactly in ComfyUI `requirements.txt` | Biweekly train: patch bump on `core/x.y`, PyPI publish, pin-bump PR on ComfyUI | Cherry-pick to `core/x.y`, patch release, new pin PR |
|
||||
|
||||
As of mid July 2026: main is at 1.48.x, cloud runs the 1.47 line, and the
|
||||
ComfyUI pin is still on 1.45.x. Three minors of distance between main and core
|
||||
GA is the normal operating state, not an anomaly.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FIX[Fix merges to main] --> NB[Nightly bump and tag] --> NU[Nightly users]
|
||||
FIX --> CLB[Cherry-pick onto cloud line] --> STG[Staging deploy] --> PRD[Prod deploy] --> CU[Cloud users]
|
||||
FIX --> COB[Cherry-pick onto core line] --> PYP[PyPI patch release] --> PIN[ComfyUI pin update] --> DU[Core and desktop users]
|
||||
```
|
||||
|
||||
Key mechanics worth naming because the strategy must preserve or deliberately
|
||||
replace each one:
|
||||
|
||||
- Every minor bump on main automatically freezes the previous minor into paired
|
||||
`core/x.y` and `cloud/x.y` branches and rotates the matching backport labels.
|
||||
- Backports are label-driven: `needs-backport` plus a target label triggers an
|
||||
automated cherry-pick PR; conflicts fall back to a documented manual path.
|
||||
- The cloud deploy already promotes build-once artifacts: assets are built one
|
||||
time per SHA, and staging and prod move by repointing that SHA. Rollback is a
|
||||
pointer revert with no rebuild.
|
||||
- Core GA promotion is a biweekly scheduled train that publishes to PyPI and
|
||||
drafts the ComfyUI pin-bump PR.
|
||||
- A rotating release owner (the "sheriff") drives promotions; per-PR backport
|
||||
ownership was deliberately moved to feature pods in June 2026.
|
||||
- Frontend builds destined for core GA soak in the core nightly channel
|
||||
(about two weeks for graph-touching changes) because there is no automated
|
||||
signal when a change breaks a community custom node. The soak is a
|
||||
compensating control for missing telemetry, not a property of safe code.
|
||||
|
||||
These mechanics are documented operationally in `docs/release-process.md`,
|
||||
which remains the runbook of record for day-to-day releases. This document
|
||||
governs the target strategy, and that runbook gets updated as each phase in
|
||||
section 8 lands.
|
||||
|
||||
## 3. Pain points of the current process
|
||||
|
||||
Numbered for traceability to section 7. All are observed, not hypothetical.
|
||||
|
||||
- **PP1. Drift compounds on long-lived lines.** The 1.45 line spans 69 days
|
||||
from its minor cut to its latest patch and is still the pinned line: 19
|
||||
patch releases, 61 commits landed on it after it froze (54 backport
|
||||
cherry-picks plus 7 patch-release bumps), and main-vs-stable divergence of
|
||||
428 PRs by the latest patch. Backports onto old lines increasingly do not
|
||||
apply cleanly, and some do not even work once applied because the
|
||||
surrounding code diverged (a fix was backported to a GA line where it could
|
||||
not function without a second dependency PR, and was ultimately abandoned
|
||||
as a known issue).
|
||||
- **PP2. Per-PR cherry-picks can silently miss a target.** A fix that needed
|
||||
two cloud lines landed on only one; the next release shipped the regression
|
||||
back to users. Three independent safeguards (notification bot, sweep check,
|
||||
PR comment) all failed to catch it (FE-713).
|
||||
- **PP3. Release batches grow superlinearly risky.** Missing one train window
|
||||
meant a double-minor release: 401 PRs validated in a single QA pass, the
|
||||
largest surface the team has ever had to certify, including high-risk
|
||||
subsystem rewrites.
|
||||
- **PP4. The backport tooling itself fails silently.** The notification bot was
|
||||
down for six weeks without anyone noticing; the repo-level auto-merge setting
|
||||
is off, which silently turned the backport auto-merge flag into a no-op and
|
||||
required a workaround workflow; the manual retry path of the backport
|
||||
workflow was broken (FE-1282).
|
||||
- **PP5. Freeze state lives in conversation, not tooling.** A verbally frozen
|
||||
line was bumped past by an engineer who reasonably read stalled automation as
|
||||
a missed dispatch. The resulting premature minor cut cost one to two weeks of
|
||||
recovery. The post-mortem's first action item: encode freeze state durably.
|
||||
- **PP6. Human bottlenecks and burnout.** One engineer ran the release rotation
|
||||
for roughly two months straight; urgent core releases have required chasing
|
||||
approvals late at night. Backport PRs add review friction (authors cannot
|
||||
always self-approve, approvers are asleep, label permissions vary).
|
||||
- **PP7. Latency invites bypasses.** A simple graph change takes a minimum of
|
||||
about 16 days to reach core GA (review, two-week soak, release), up to 27 if
|
||||
it just misses a window. Under deadline pressure a customer demo was shipped
|
||||
via a one-off deployment that routed around the release process entirely,
|
||||
creating an unowned production surface.
|
||||
- **PP8. Release state is hard to read.** Answering "what exactly is on cloud
|
||||
prod" requires cross-referencing a branch tip, a deploy tag, and a SHA in
|
||||
another repo's values file. Public release surfaces (GitHub releases, docs
|
||||
changelog, in-app update notice) have drifted out of sync.
|
||||
- **PP9. QA involvement is ad hoc.** Test plans are hand-built per release
|
||||
(one release needed a bespoke plan naming 84 high-risk PRs); there is no
|
||||
standing definition of entry and exit criteria per promotion gate.
|
||||
- **PP10. Everything above burns cross-team trust.** Slow stable releases are
|
||||
a recurring source of cross-team friction, and the release rotation absorbs
|
||||
that pressure personally.
|
||||
|
||||
## 4. Review of the draft 4-tier proposal
|
||||
|
||||
### 4.1 The proposed model
|
||||
|
||||
The draft proposes four long-lived branches, each auto-deploying to its own
|
||||
standing environment, promoted wholesale (no cherry-picks) by a release
|
||||
manager, with hotfixes cut from main re-entering through staging, and a
|
||||
main-to-testing back-merge closing each cycle:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T[testing branch and env] -->|release manager merge| D[dev branch and env]
|
||||
D -->|release manager merge| S[staging branch and env]
|
||||
S -->|release manager merge| M[main branch equals prod]
|
||||
M -.->|back-merge at cycle end| T
|
||||
M -->|urgent defect| H[hotfix branch] --> S
|
||||
```
|
||||
|
||||
Claimed properties: engineers merge freely into testing; dev is a guaranteed
|
||||
stable baseline; staging mirrors production for UAT; main is 1-to-1 with what
|
||||
is deployed, "eliminating the complicated and error-prone process of
|
||||
back-porting entirely."
|
||||
|
||||
This model was reviewed against the org context in sections 2 and 3, the
|
||||
industry evidence in section 5, and an adversarial defense pass (a reviewer
|
||||
whose explicit job was to defend the proposal and refute the critique; several
|
||||
initial critique points were withdrawn or narrowed as a result and are noted
|
||||
below).
|
||||
|
||||
### 4.2 What the proposal gets right
|
||||
|
||||
These instincts are correct, and each is adopted in section 6 via a cheaper
|
||||
mechanism than a branch tier:
|
||||
|
||||
- **K1. Deployed state should be inspectable in git.** Today it is not (PP8).
|
||||
Adopted as a machine-maintained prod pointer (6.5).
|
||||
- **K2. Freezes must be enforced by tooling, not by verbal agreement.** The
|
||||
premature-bump incident (PP5) proves it. Adopted as freeze-as-code (6.6).
|
||||
- **K3. Promotions need named owners and explicit gates.** Adopted via GitHub
|
||||
Environments protection rules and the existing rotation (6.4).
|
||||
- **K4. Engineers need a guaranteed-good base to branch from when trunk is
|
||||
red.** Adopted as last-green stable tags on main (6.7).
|
||||
- **K5. Fix propagation needs a hard discipline.** The draft's down-merge SLA
|
||||
becomes the inverse and industry-standard rule: upstream first, fix lands on
|
||||
main before any release line (6.3).
|
||||
- **K6. Whole-unit promotion beats per-PR picking wherever a track allows it.**
|
||||
Wholesale promotion makes the PP2 failure class (a missed per-PR pick)
|
||||
unrepresentable. Adopted for the cloud track as whole-artifact promotion,
|
||||
and bounded on the core track by a drift SLO (6.8).
|
||||
- **K7. Every promotion should auto-deploy its surface.** Directionally right
|
||||
and already sanctioned by in-flight work that removes no-value manual steps
|
||||
(FE-1176). Adopted throughout.
|
||||
- **K8. QA deserves a first-class, named slot in the release path.** The only
|
||||
document in this debate that gives QA an explicit stage. Adopted as defined
|
||||
entry/exit criteria per gate instead of a dedicated environment (6.4).
|
||||
|
||||
### 4.3 Findings
|
||||
|
||||
Ordered by severity. "Withdrawn" notes mark initial critiques that did not
|
||||
survive the adversarial defense, kept here so the review is honest about its
|
||||
own error bars.
|
||||
|
||||
**Fatal (any one of these blocks adoption as written):**
|
||||
|
||||
- **F1. The central claim is false for this product.** "Eliminates
|
||||
back-porting entirely" cannot hold while ComfyUI core pins an exact
|
||||
`comfyui-frontend-package` version and desktop users run pinned installs.
|
||||
A linear four-tier chain holds exactly one version in flight; it has no
|
||||
mechanism to patch a shipped 1.45 while 1.47 is mid-promotion and 1.48 is
|
||||
on nightly. Fixes to pinned lines remain cherry-picks plus PyPI patch
|
||||
releases, that is, backports. The proposal eliminates the word by omitting
|
||||
the surface that needs it: nowhere does it mention the pin, PyPI, desktop,
|
||||
the soak, or custom nodes. (Narrowed but confirmed under defense: wholesale
|
||||
trains do reduce drift-driven backport volume; they do not remove the
|
||||
pinned-version axis.)
|
||||
- **F2. It re-models an existing artifact pipeline as merge ceremony, and
|
||||
regresses it.** Cloud promotion is already build-once: one SHA-keyed asset
|
||||
build, promoted by pointer, rollback with no rebuild. Branch-tier CD means
|
||||
each tier builds its own merge commit, so the artifact validated on staging
|
||||
is provably not the artifact deployed from main. Worse, GitHub pull request
|
||||
merges never fast-forward, so gated promotion PRs mint a new SHA at every
|
||||
tier: "human-gated promotion" and "main is SHA-identical to prod" are
|
||||
mutually exclusive with native GitHub mechanics. One of the model's two
|
||||
core promises must break.
|
||||
- **F3. The soak has no home.** The two-week custom-node soak is the org's
|
||||
central regression control for its worst historical failure class. In a
|
||||
linear chain it either occupies the staging tier permanently (capping all
|
||||
release cadence at soak length and colliding with the hotfix path) or
|
||||
silently disappears while its automated replacement remains unstaffed.
|
||||
- **F4. It rows against the org's sanctioned direction.** The shipping-speed
|
||||
initiative targets PR-to-prod under 48 hours, backport rate under 10
|
||||
percent, and stable tags cut from main every 24 to 48 hours, backed by an
|
||||
internal PRD and in-flight Linear work (FE-1176, FE-602/BE-800, release
|
||||
gate automation). The draft adds an N-day merge freeze (at roughly 330
|
||||
merged PRs per month across all targets, about 230 of them on main, that
|
||||
is a real stall), three human promotion gates,
|
||||
and full-cycle latency for every change. DORA's trunk research lists "no
|
||||
code freezes" as a success criterion for elite delivery.
|
||||
|
||||
**Major:**
|
||||
|
||||
- **F5. The hotfix path is inoperable mid-cycle.** Hotfixes cut from main
|
||||
re-enter through staging. Whenever staging holds next-cycle content in UAT
|
||||
(most of the calendar), an urgent prod fix either waits out UAT or drags
|
||||
unreleased work to production. At the observed fix rate on live lines
|
||||
(54 backports in the 53 days after the 1.45 line froze, about one per
|
||||
day), "restart UAT on every hotfix" is a validation livelock. Notably, both parents of this model do it differently:
|
||||
GitLab Flow is strictly upstream-first, and GitFlow merges hotfixes to
|
||||
master directly. The draft inherits the weaker property of each.
|
||||
- **F6. "Guaranteed stable dev" is a smoke test, not a guarantee.** dev
|
||||
receives testing wholesale, so at promotion time dev is byte-identical to
|
||||
testing at freeze. The only stability delta is whatever a quick smoke pass
|
||||
catches, and the tier's only unique content comes from direct-to-dev
|
||||
hotfixes, which is the environment-drift anti-pattern. A second standing
|
||||
environment running identical code adds configuration-drift false positives,
|
||||
not signal. (Narrowed under defense: as a lagged checkpoint, dev does have
|
||||
value mid-cycle; but a last-green tag delivers the same checkpoint without
|
||||
a branch, an environment, or a gate.)
|
||||
- **F7. Every gate is human and every suite is undefined.** The model's
|
||||
stability claims rest entirely on "smoke tests," "all regression and
|
||||
acceptance tests," and release-manager judgment. No suite, owner, or pass
|
||||
criterion is named anywhere. Against roughly 330 PRs per month with a
|
||||
two-person QA function, undefined manual gates become either the bottleneck
|
||||
or a rubber stamp; the 401-PR pass (PP3) becomes the steady state, since
|
||||
biweekly wholesale promotion at current velocity is roughly 110 to 165 PRs
|
||||
per batch (main-line content alone runs about 110 per two weeks).
|
||||
- **F8. No rollback story, and env-branch rollback poisons future
|
||||
promotions.** The draft never mentions rollback. Reverting a bad promotion
|
||||
merge makes git treat that content as already-merged, silently dropping it
|
||||
from the next wholesale promotion until someone reverts the revert. And the
|
||||
moment a prod deploy fails after the staging-to-main merge, main is ahead of
|
||||
prod again, the exact state the model claims to abolish.
|
||||
- **F9. The four auto-deploy pipelines do not exist and cannot be driven from
|
||||
this repo.** This repo never deploys anything; it fire-and-forgets a
|
||||
dispatch to the cloud repo, which owns builds, secrets, and ArgoCD. Standing
|
||||
up per-branch CD means building four cross-repo deploy paths while the
|
||||
backend concurrently moves to per-service delivery with its own promotion
|
||||
model. The proposal is also purely additive: since it cannot serve the
|
||||
pinned-package axis (F1), all existing release machinery keeps running
|
||||
beside it, and the net delta is plus three eternal branches, plus one or two
|
||||
standing environments, plus three human gates.
|
||||
|
||||
**Spec gaps and smaller issues:**
|
||||
|
||||
- **F10. Wholesale-minus-exceptions needs a written procedure.** Excluding one
|
||||
bad PR from a promotion is revert-then-reland (and revert-the-revert later),
|
||||
a known git footgun that will otherwise be improvised for the first time
|
||||
during a release emergency. The draft's own exception clause re-authorizes
|
||||
the cherry-picking it bans. (Initial claim "this is not a git operation" was
|
||||
withdrawn: revert/reland is standard practice; the finding is that the
|
||||
procedure is unwritten and its failure mode, silently dropped features, has
|
||||
no detecting check.)
|
||||
- **F11. Freeze semantics are underspecified rather than contradictory.**
|
||||
(Initial "rules 1.c and 1.d contradict" was withdrawn.) The real gap:
|
||||
stabilization fixes must merge during the freeze, so the freeze is porous by
|
||||
design, with no tooling-enforced definition of what may enter, which is
|
||||
exactly the un-encoded-freeze failure mode of PP5.
|
||||
- **F12. Naming and contract frictions.** A tier named dev that is more stable
|
||||
than testing inverts every industry convention; repointing main's semantics
|
||||
would break the nightly channel contract, though the model does not actually
|
||||
require the top tier to be named main (withdrawn as a fatal objection: tier
|
||||
names are free variables; kept as a migration note). Rule 6.a's ancestry
|
||||
policy has no native GitHub primitive and needs a small custom status check;
|
||||
cheap, but this org's post-mortems document exactly this class of bespoke
|
||||
automation failing silently (PP4).
|
||||
|
||||
### 4.4 Scorecard
|
||||
|
||||
| Dimension | Score | Basis |
|
||||
| --- | --- | --- |
|
||||
| Internal coherence | 5/10 | F5, F8, F10, F11 are real; several initial coherence critiques were withdrawn under defense |
|
||||
| Fit to this product's topology | 3/10 | F1, F3: the pinned-package axis and the soak are unmodeled |
|
||||
| Operability on GitHub with current infra | 2/10 | F2 SHA exclusivity, F9 pipelines do not exist, additive process surface |
|
||||
| Alignment with current industry evidence | 3/10 | Section 5; env-branch promotion is a documented anti-pattern for continuously deployed surfaces, with narrow exceptions that map to pipeline approval gates |
|
||||
| Quality of underlying instincts | 8/10 | K1 through K8 are correct and adopted |
|
||||
| **Overall, as written, for this repo** | **4/10** | Fatal findings F1 through F4 |
|
||||
|
||||
For fairness: as a generic process for a single-track web application in the
|
||||
era it comes from, this model rates roughly 6.5/10; GitLab Flow's environment
|
||||
branch variant, which it closely resembles, remains documented practice. The
|
||||
low score here is about fit to this product, not about the model's pedigree.
|
||||
|
||||
### 4.5 Disposition of each proposal element
|
||||
|
||||
| Proposal element | Disposition | Why |
|
||||
| --- | --- | --- |
|
||||
| Four long-lived environment branches | Reject | F1, F2, F3, F9; environments become pipeline stages instead |
|
||||
| Wholesale promotion, no cherry-picks | Adapt | Correct for cloud as whole-artifact promotion (K6); impossible for the pinned core axis (F1) |
|
||||
| Release-manager gated merges at three tiers | Adapt | One named human gate per surface via GitHub Environments required reviewers; no new role hierarchy (PP6) |
|
||||
| N-day code freeze on the integration branch | Reject | F4; freezes are the documented anti-pattern and main never freezes today |
|
||||
| Anything in testing ships in the current cycle | Adapt | Correct WIP-limit instinct; becomes the enforceable drift SLO in 6.8 |
|
||||
| dev tier as stable baseline | Replace | Last-green stable tags on main (K4, F6) |
|
||||
| staging tier for UAT and alpha access | Keep the capability | Already exists as the staging environment plus auth-gated and per-PR preview deploys; formalize QA criteria (K8) |
|
||||
| main equals prod, 1-to-1 | Replace | Machine-maintained prod pointer ref (K1); merge-based parity is unenforceable on GitHub (F2) |
|
||||
| Hotfix via branch from main through staging | Reject | F5; hotfix path per track defined in 6.9 |
|
||||
| Back-merge main to testing at cycle end | Reject | Unnecessary once there is a single eternal branch; upstream-first makes down-merges structural (K5) |
|
||||
| GitHub-enforced containment policy (6.a) | Adopt the idea | As a 20-line ancestry status check plus release-content verification (6.8), with heartbeat alerts given PP4 |
|
||||
| Version tag on every prod deploy | Already exists | Cloud deploy tags and nightly tags; kept and unified in the prod pointer spec |
|
||||
|
||||
## 5. What the industry does
|
||||
|
||||
Genealogy first, because the draft cites a remembered decade-old post with
|
||||
detailed charts. No post titled "a better git branching strategy" from that
|
||||
era appears to exist. The remembered title and charts almost certainly belong
|
||||
to Vincent Driessen's "A successful Git branching model" (nvie.com, 2010, the
|
||||
GitFlow post). The remembered content, four auto-deploying environment tiers
|
||||
promoted by wholesale merge, matches GitLab Flow's environment-branches
|
||||
variant (2014). The hotfix-from-prod and back-merge mechanics are GitFlow's.
|
||||
The draft is therefore a hybrid of two models from 2010 to 2014, both of whose
|
||||
authors have since published significant caveats:
|
||||
|
||||
- Driessen added a note to the GitFlow post in 2020: teams shipping
|
||||
continuously delivered web software should use a much simpler flow such as
|
||||
GitHub Flow; GitFlow-style models remain reasonable for explicitly versioned
|
||||
software with multiple versions in the wild. Both halves apply here, because
|
||||
this product is both.
|
||||
- GitLab's own docs now describe the environment-branch variant alongside a
|
||||
release-branch variant with an explicit upstream-first rule (fix on main,
|
||||
cherry-pick down), the same policy Google and Red Hat practice.
|
||||
|
||||
The current consensus, by product shape:
|
||||
|
||||
- **Continuously deployed services** (GitHub, Shopify, Google, the DORA
|
||||
research corpus): one mainline, short-lived topic branches, merge queue,
|
||||
feature flags, canary or percentage rollout. Environments are deployment
|
||||
pipeline stages or GitOps folders, not branches. DORA's trunk criteria:
|
||||
three or fewer active branches, daily merges, no code freezes; elite
|
||||
performers correlate strongly with this shape. Environment-branch promotion
|
||||
is repeatedly documented as an anti-pattern (Fowler's branching patterns;
|
||||
the GitOps literature: merge-order skew, unintended config riding along,
|
||||
undocumented prod drift from direct-to-branch hotfixes, per-branch rebuilds
|
||||
violating build-once). The one conceded exception, regulated sign-off audit
|
||||
trails, is satisfied by pipeline approval gates with deployment history.
|
||||
- **Versioned or embedded software** (Chrome, Firefox, Microsoft Release
|
||||
Flow): trunk plus short-lived per-release branches, cut just in time,
|
||||
fix-only, upstream-first cherry-picks with approval, never merged back,
|
||||
retired when the version leaves support. Chrome ships a milestone every four
|
||||
weeks this way; Firefox calls the cherry-pick an uplift and gates it on
|
||||
release management approval. The heavyweight tooling both maintain is
|
||||
evidence that backports are a cost to be minimized, not a routine channel.
|
||||
- **Hybrid products like this one** split the axes: trunk plus pipeline
|
||||
promotion and flags for the continuously deployed surface; trunk plus
|
||||
short-lived release branches for the pinned, versioned surface. That is
|
||||
Microsoft Release Flow on one side and Chrome-style trains on the other,
|
||||
sharing one trunk.
|
||||
|
||||
Sources: nvie.com/posts/a-successful-git-branching-model,
|
||||
about.gitlab.com/topics/version-control/what-is-gitlab-flow,
|
||||
dora.dev/capabilities/trunk-based-development,
|
||||
martinfowler.com/articles/branching-patterns.html,
|
||||
trunkbaseddevelopment.com/branch-for-release,
|
||||
octopus.com/blog/stop-using-branches-deploying-different-gitops-environments,
|
||||
beyond.minimumcd.org/docs/reference/practices/immutable-artifacts,
|
||||
devblogs.microsoft.com/devops/release-flow-how-we-do-branching-on-the-vsts-team,
|
||||
chromium.googlesource.com/chromium/src/+/master/docs/process/release_cycle.md,
|
||||
wiki.mozilla.org/Release_Management/Release_Process,
|
||||
github.blog/engineering/engineering-principles/deploying-branches-to-github-com,
|
||||
shopify.engineering/successfully-merging-work-1000-developers.
|
||||
|
||||
## 6. Recommended strategy
|
||||
|
||||
### 6.1 Principles
|
||||
|
||||
- **P1. One eternal branch.** main is the single integration branch and the
|
||||
nightly channel. Its semantics are a locked contract: nightly releases,
|
||||
npm type publishing, cloud build dispatch, CI triggers, and community
|
||||
tooling all key off it.
|
||||
- **P2. Build once, promote artifacts.** Every merge to main produces one
|
||||
SHA-keyed artifact. Environments receive that artifact by pointer; nothing
|
||||
is ever rebuilt per environment. The artifact that passes staging and canary
|
||||
is byte-identical to the artifact in production.
|
||||
- **P3. Upstream first, always.** Every fix lands on main first, with tests.
|
||||
Release lines receive changes only via the backport pipeline. Direct commits
|
||||
to `core/*` and `cloud/*` are blocked for humans (release automation
|
||||
excepted). This is the rule that prevents the PP2 class.
|
||||
- **P4. Branches only where a version demands one.** A long-lived branch
|
||||
exists only to serve a pinned, shipped version (`core/x.y` while ComfyUI
|
||||
pins it). No branch exists to represent an environment.
|
||||
- **P5. Invariants over notifications.** Every policy in this document that
|
||||
matters is enforced by a required check or a reconciling audit with
|
||||
heartbeat alerting, not by a bot that posts a message. PP4 is the reason.
|
||||
- **P6. Gates are automation-first, with at most one named human approval per
|
||||
surface,** held by the existing release rotation. No new role hierarchy.
|
||||
- **P7. Small batches on a fixed cadence.** The unit of release stays as small
|
||||
as the gates allow. The 401-PR batch is the documented anti-goal.
|
||||
|
||||
### 6.2 Branch roles
|
||||
|
||||
| Branch | Lifetime | Purpose | Who writes to it |
|
||||
| --- | --- | --- | --- |
|
||||
| `main` | Eternal | Integration, nightly channel, source of all builds | Engineers via peer-approved PRs |
|
||||
| `core/x.y` | Weeks; dies when the ComfyUI pin moves off x.y | Serve the pinned PyPI line | Backport automation and release bumps only |
|
||||
| `cloud/x.y` | Interim only; retired per 6.10 | Cloud release train until cloud CD lands | Backport automation only |
|
||||
| `feature/*`, `fix/*` | Days | Topic branches off main (or a last-green tag when main is red) | The author |
|
||||
| Prod pointer ref (`deployed/cloud-prod`) | Eternal, machine-written | Mirrors the verified deployed SHA after every prod sync, rollbacks included | Deploy pipeline only |
|
||||
|
||||
Target state, Phase 2 and later (until the cloud branch retirement in 6.10
|
||||
lands, the cloud leg still promotes from the interim cloud/x.y line):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ENG[Engineer merges PR to main] --> BUILD[One artifact built per SHA]
|
||||
BUILD --> NIGHT[Nightly tag and release]
|
||||
BUILD --> TC[testcloud] --> SC[staging] --> CAN[Canary percent rollout] --> PROD[Cloud prod]
|
||||
PROD --> PTR[Prod pointer ref advances]
|
||||
ENG --> GATE[Overnight release gate CI] --> TAGS[Last-green stable tag]
|
||||
TAGS -->|next minor cut| LINE[core release line, backports only] --> TRAIN[Biweekly core train: patch bump, PyPI, pin PR]
|
||||
```
|
||||
|
||||
### 6.3 The two release axes, explicitly separated
|
||||
|
||||
- **Cloud axis (continuous):** main to testcloud to staging to prod is
|
||||
artifact promotion through pipeline stages. Gates live in the pipeline: the
|
||||
overnight release-gate suite for candidate selection, a staging smoke
|
||||
checklist with named QA criteria, and canary metrics with automatic
|
||||
rollback once the canary program lands. Incomplete features land on main
|
||||
dark behind feature flags so the trunk stays releasable while work is in
|
||||
progress. No environment branches.
|
||||
- **Core axis (versioned):** `core/x.y` branches exist because an exact
|
||||
version is pinned by another product and shipped to desktops. They are cut
|
||||
automatically (as today; in the target state the cut is taken from the
|
||||
newest last-green tag rather than the raw pre-bump commit), receive fixes
|
||||
only via upstream-first backports, and are retired when the pin moves. The fix for PP1 is not a new topology;
|
||||
it is shortening how long these lines live and how far they drift (6.8).
|
||||
|
||||
### 6.4 Promotion gates
|
||||
|
||||
Implemented as GitHub Environments deployment protection rules (required
|
||||
reviewers plus deployment history), not as branch merges. This natively
|
||||
provides the audit trail that is the one legitimate case for human-gated
|
||||
promotion, and it is the same primitive the draft's release-manager gates
|
||||
actually wanted.
|
||||
|
||||
| Gate | Trigger | Automated criteria | Human |
|
||||
| --- | --- | --- | --- |
|
||||
| PR into main | Every PR | Unit, component, lint, typecheck | One peer approval |
|
||||
| Release-gate verdict | Nightly | Behavioral suite, critical-path browser tests, custom-node harness when live | None; red verdict is an incident |
|
||||
| Staging promotion | On green candidate | Artifact exists, gate verdict green | None (auto) |
|
||||
| Prod promotion | Sheriff action | Staging smoke checklist green | One: rotation owner |
|
||||
| Core GA train | Biweekly schedule | Line is green, soak or harness criteria met | One: pin PR merge in ComfyUI |
|
||||
|
||||
QA's slot (K8): the staging smoke checklist and the release regression scope
|
||||
are standing documents with named QA owners and defined entry and exit
|
||||
criteria, replacing hand-built per-release plans (PP9).
|
||||
|
||||
### 6.5 Production visibility (adopts K1)
|
||||
|
||||
A machine-maintained ref, `deployed/cloud-prod`, is written by the deploy
|
||||
pipeline only after the production sync reports healthy, and it mirrors the
|
||||
deployed SHA in both directions: a verified rollback moves the ref backward
|
||||
to the rolled-back SHA (a recorded, non-fast-forward move with the rollback
|
||||
reason logged, which the reconciler treats as healthy), so the ref never lies
|
||||
about production during an incident. A reconciling check compares the ref against the cloud repo's
|
||||
deployed SHA on every deploy and alerts on mismatch (P5). Answering "what is on prod" becomes
|
||||
`git log deployed/cloud-prod`, which is the legitimate requirement behind the
|
||||
draft's main-equals-prod rule, delivered without merge ceremony and immune to
|
||||
the F2 impossibility.
|
||||
|
||||
### 6.6 Freeze-as-code (adopts K2)
|
||||
|
||||
Freeze state is a first-class marker (a repo variable or protected file) that
|
||||
release automation checks before acting: the minor-bump dispatch refuses to
|
||||
cut past a frozen line, and backport targeting warns on frozen targets. All
|
||||
release bots carry heartbeat alerts; silence is an incident. These are the
|
||||
premature-bump post-mortem action items, promoted into the strategy.
|
||||
|
||||
### 6.7 Stable base for engineers (adopts K4)
|
||||
|
||||
The nightly release-gate verdict stamps an immutable dated tag (for example
|
||||
`stable/2026-07-18`) on the newest commit that passed the full gate and
|
||||
advances a `stable/latest-green` branch-style ref to it. A moving ref, not a
|
||||
moving tag, on purpose: git clients do not force-update moved tags by
|
||||
default, so a moving tag would silently go stale locally. When main is red,
|
||||
engineers branch from the ref or the dated tag instead of a standing dev
|
||||
branch. Red main is itself an incident
|
||||
with a named owner, which is what actually keeps the trunk usable.
|
||||
|
||||
### 6.8 Drift limits and content verification (adopts K6 and the containment idea)
|
||||
|
||||
- **Drift SLO:** the pinned release line being older than 28 days, or the pin
|
||||
sitting more than two minors behind main, pages the rotation owner and
|
||||
forces a train decision. This is the WIP limit that the draft expressed as
|
||||
"anything in testing ships this cycle," made enforceable. PP1's 69-day line
|
||||
becomes structurally impossible to reach silently. A breach also blocks the
|
||||
next minor cut until acknowledged. The SLO activates with a burn-in
|
||||
exemption for the already-breached 1.45 line, and a breach that traces to
|
||||
an unmerged ComfyUI pin PR escalates per risk R2 instead of paging the
|
||||
rotation, because that lever is not frontend-side.
|
||||
- **Convergence invariant:** a scheduled check asserts every commit on a live
|
||||
release line is an ancestor of main or arrived via the backport pipeline,
|
||||
and that every prod-deployed SHA is reachable from a release line. Roughly
|
||||
20 lines of CI; catches what three notification bots missed (PP2).
|
||||
- **Release-content verification:** before a train departs, an automated
|
||||
check confirms every PR labeled for that line actually landed on it, and
|
||||
posts the diff of intent versus content. FE-713 becomes a failing check
|
||||
instead of a user report.
|
||||
|
||||
### 6.9 Hotfix and rollback runbooks
|
||||
|
||||
Interim topology (while cloud/x.y lines exist; after the 6.10 retirement the
|
||||
cloud leg promotes last-green artifacts from main instead):
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Q1{Where is the defect live?} -->|Nightly only| A1[Fix on main, ships next nightly]
|
||||
Q1 -->|Cloud prod| A2[Fix on main with tests] --> A3[Backport label to the live cloud line] --> A4[Staging smoke on the patched artifact] --> A5[Prod promotion by rotation owner]
|
||||
Q1 -->|Core GA or desktop| A6[Fix on main with tests] --> A7[Backport label to the pinned core line] --> A8[Patch release to PyPI] --> A9[Pin bump PR on ComfyUI]
|
||||
Q1 -->|Deploy itself is bad, code is fine| R1[Rollback: repoint prod to previous SHA, no rebuild]
|
||||
```
|
||||
|
||||
Rules: hotfixes never restart full UAT and never bundle unreleased content;
|
||||
validation scope is the affected area plus the standing smoke checklist. The
|
||||
sanctioned path must stay hours-scale (target: under 8 working hours from
|
||||
fix merged on main to prod promotion), because the org has already
|
||||
demonstrated that a slower sanctioned path manufactures shadow deploy surfaces
|
||||
(PP7). Rollback is always a pointer move, never a git revert of a promotion;
|
||||
revert is reserved for code defects on main with an explicit reland step.
|
||||
|
||||
### 6.10 Cloud branch retirement (the end state for the cloud axis)
|
||||
|
||||
`cloud/x.y` branches exist today because cloud deploys from a frozen line.
|
||||
Once the release-gate suite plus canary auto-rollback are proven (several
|
||||
clean ramps and at least one real auto-rollback in production), cloud promotes
|
||||
last-green artifacts from main directly and the cloud branch family retires.
|
||||
The cutover checklist must cover: release-branch creation stops cutting
|
||||
`cloud/<minor>` and rotating cloud labels, the cloud deploy tag workflow
|
||||
retires, build dispatch keys on main SHAs and promotion events, and testcloud
|
||||
repoints from the cloud line tip to last-green tags. Until then, cloud/x.y
|
||||
continues exactly as today; this document changes nothing about it yet.
|
||||
|
||||
## 7. How this solves the current pain points
|
||||
|
||||
| Pain point | Mechanism in this strategy | Status |
|
||||
| --- | --- | --- |
|
||||
| PP1 drift on long lines | Drift SLO (6.8) caps line age; shorter trains; upstream-first keeps lines fix-only | Mitigated; fully solved only if ComfyUI adopts pin bumps at train cadence (risk R2) |
|
||||
| PP2 missed per-PR backports | Release-content verification plus convergence invariant (6.8); whole-artifact promotion on cloud removes per-PR picks there entirely | Solved: becomes a failing check, and unrepresentable on the cloud axis post 6.10 |
|
||||
| PP3 giant QA batches | Fixed cadence with drift SLO forces small trains (P7); release-gate CI carries per-merge burden | Mitigated; batch size cannot silently grow past the SLO, and drops under 100 if the 13.1 decision moves the core train to weekly |
|
||||
| PP4 silent tooling failure | Invariants and reconciling audits with heartbeat alerts replace notification-only bots (P5); prerequisite fixes named in 8 | Mitigated; bots can still break, but silence itself now alarms |
|
||||
| PP5 verbal freezes | Freeze-as-code guards the bump and backport paths (6.6) | Solved: the premature-bump incident becomes mechanically impossible |
|
||||
| PP6 human bottleneck | One human gate per surface on the existing rotation; merge-on-green everywhere else; backport approval rules simplified by making automation the only writer to release lines | Mitigated; the rotation remains, its late-night surface shrinks |
|
||||
| PP7 latency invites bypasses | Hours-scale sanctioned hotfix lane (6.9); canary replaces calendar soak for cloud; core latency shrinks as the harness replaces the soak | Partially solved now, fully contingent on the custom-node harness (risk R1) |
|
||||
| PP8 unreadable release state | Prod pointer ref (6.5); unified tags; one strategy document | Solved |
|
||||
| PP9 ad hoc QA | Standing gate criteria with named QA owners (6.4) | Solved by process definition |
|
||||
| PP10 cross-team trust | Publish the delivery metrics (9) on a fixed cadence so progress is legible instead of argued | Mitigated; trust follows the numbers |
|
||||
|
||||
The honest line on backports: this strategy does not eliminate them, because
|
||||
nothing can while another product pins an exact version. It makes them rare
|
||||
(drift SLO plus faster trains), safe (upstream-first plus content
|
||||
verification), and boring (automation is the only writer to release lines).
|
||||
The draft's promise was elimination; the achievable promise is a backport
|
||||
rate under 10 percent with no silent misses.
|
||||
|
||||
## 8. Rollout plan
|
||||
|
||||
Prerequisites (fix before anything else changes): repair the manual backport
|
||||
retry workflow (FE-1282), turn on the repo auto-merge setting and retire the
|
||||
cron workaround, add heartbeat alerts to every release bot, and correct the
|
||||
stale PyPI-attribution in docs/release-process.md so the runbook of record
|
||||
matches the actual workflows.
|
||||
|
||||
- **Phase 0, immediately:** adopt this document; freeze-as-code; prod pointer
|
||||
ref; drift SLO alerting; convergence invariant check; standing QA gate
|
||||
criteria drafted (their operational acceptance is P1 item 9). No branch
|
||||
topology changes at all.
|
||||
- **Phase 1, with the release gate:** overnight release-gate suite produces
|
||||
last-green tags; stable tags become the engineer base and the cloud
|
||||
candidate source; staging promotion goes automatic on green.
|
||||
- **Phase 2, with the canary:** percentage rollout with metric-gated
|
||||
auto-ramp and auto-rollback on cloud prod; hotfix lane switches to
|
||||
canary-validated promotion.
|
||||
- **Phase 3, retirement:** cloud/x.y family retires per 6.10. Core/x.y
|
||||
remains, on shorter trains, as long as ComfyUI pins exact versions; the
|
||||
calendar soak retires only when the custom-node harness is live, staffed,
|
||||
and has held the escaped-regression guardrail flat for two full cycles.
|
||||
|
||||
## 9. Goals and success metrics
|
||||
|
||||
| Goal | Metric | Target | Horizon |
|
||||
| --- | --- | --- | --- |
|
||||
| Ship fast | PR merge to cloud prod | Under 48 hours median | Phase 2 |
|
||||
| Backports rare | Backported PRs / merged PRs | Under 10 percent | Phase 2 |
|
||||
| Lines stay young | Max live release-line age | Under 28 days | Phase 1 |
|
||||
| No silent misses | Escaped regressions from missed backports | Zero | Phase 0 |
|
||||
| Batches stay small | PRs per QA-certified release | Under 100 | Phase 2, after the 13.1 cadence decision |
|
||||
| Prod is legible | Time to answer "what is on prod" | One git command | Phase 0 |
|
||||
| No shadow deploys | Unowned one-off production surfaces | Zero new | Phase 0 |
|
||||
|
||||
Leading indicators are the first three; report all seven on a fixed cadence in
|
||||
the cross-team channel (PP10).
|
||||
|
||||
## 10. Non-goals
|
||||
|
||||
- Renaming or repointing main. Its contract is locked (P1).
|
||||
- Eliminating release branches while ComfyUI pins exact frontend versions.
|
||||
That coupling is a product decision owned elsewhere; this strategy minimizes
|
||||
its cost rather than pretending it away.
|
||||
- Standing up new long-lived environments. Existing surfaces (testcloud,
|
||||
staging, per-PR previews) cover every tier the draft wanted.
|
||||
- Changing ComfyUI core's own release process, cadence, or the soak policy it
|
||||
requires; this document only defines what the frontend does on each axis.
|
||||
- Prescribing the backend or cloud repo's deployment architecture (per-service
|
||||
delivery work proceeds independently).
|
||||
|
||||
## 11. Requirements
|
||||
|
||||
**P0 (the strategy is not adopted without these):**
|
||||
|
||||
1. Freeze-as-code marker checked by version-bump and backport automation.
|
||||
Acceptance: a bump dispatch against a frozen line fails with a clear error;
|
||||
verified by test.
|
||||
2. Prod pointer ref written only by the deploy pipeline after verified sync,
|
||||
with a reconciling audit. Acceptance: mismatch alarms within one deploy
|
||||
cycle.
|
||||
3. Convergence invariant and release-content verification checks, with
|
||||
heartbeat alerting. Acceptance: a deliberately mislabeled test PR is caught
|
||||
before a train departs.
|
||||
4. Drift SLO alerting at 28 days of line age or two minors of pin divergence.
|
||||
Acceptance: alert fires in a rehearsal against a stale line.
|
||||
5. Documented hotfix and rollback runbooks (6.9) with an hours-scale SLA.
|
||||
Acceptance: one rehearsed hotfix per quarter meets the SLA.
|
||||
6. Prerequisite tooling fixes: FE-1282, auto-merge setting, bot heartbeats.
|
||||
Acceptance: a manually dispatched backport retry succeeds end to end; a
|
||||
backport PR merges via repo auto-merge with the cron workaround retired;
|
||||
killing a release bot raises its heartbeat alert.
|
||||
|
||||
**P1 (fast follows):**
|
||||
|
||||
7. Overnight release-gate suite stamping last-green tags; red verdict is an
|
||||
incident with a named owner. Acceptance: a green run stamps the dated tag
|
||||
within an hour; a red run opens an owned incident.
|
||||
8. Automatic staging promotion on green; GitHub Environments protection rules
|
||||
with deployment history as the prod gate. Acceptance: a green candidate
|
||||
reaches staging with zero human actions, and a prod promotion without the
|
||||
required reviewer is blocked in rehearsal.
|
||||
9. Standing QA gate criteria with named owners replacing per-release plans.
|
||||
Acceptance: the next release runs from the standing criteria with no
|
||||
bespoke test plan authored.
|
||||
|
||||
**P2 (future, explicitly sequenced):**
|
||||
|
||||
10. Canary with metric-gated auto-ramp and auto-rollback. Acceptance: one
|
||||
production auto-rollback triggered by canary metrics in a game day.
|
||||
11. cloud/x.y retirement per the 6.10 checklist. Acceptance: the cutover
|
||||
checklist fully executed, verified by a workflow inventory showing no
|
||||
half-dead machinery.
|
||||
12. Soak retirement gated on the custom-node harness guardrail. Acceptance:
|
||||
the harness holds the escaped-regression guardrail flat for two full
|
||||
cycles before any soak shortening.
|
||||
|
||||
## 12. Risk register
|
||||
|
||||
| ID | Risk | L | I | Level | Mitigation | Owner |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| R1 | Custom-node harness stays unstaffed, so soak cannot shrink and core latency persists | High | High | Critical | Escalate staffing as the single gating dependency of the whole program; do not shorten the soak before the harness holds the guardrail | Eng leadership |
|
||||
| R2 | ComfyUI pin adoption lags frontend trains, so drift SLO breaches are unresolvable frontend-side | High | High | Critical | Agree a pin-adoption SLA with core release stakeholders; SLO breaches tracing to an unmerged pin PR escalate to leadership instead of paging the rotation | FE eng manager with core stakeholders |
|
||||
| R3 | Canary program slips; cloud branches linger in a half-retired state | Med | High | High | Retirement is the last step with explicit cutover criteria; interim state is exactly today's process | Release pipeline owner |
|
||||
| R4 | Prod pointer or invariant checks silently break, recreating PP4 | Med | High | High | Heartbeat alerts plus reconciliation against the deploy system on every run; silence pages | DevOps |
|
||||
| R5 | Drift SLO is ignored under deadline pressure | Med | Med | Medium | SLO breach pages the rotation and blocks the next minor cut until acknowledged | Rotation owner |
|
||||
| R6 | Incremental rollout gives no visible "fixed" moment; cross-team pressure continues | Med | Med | Medium | Publish the section 9 metrics on a fixed cadence; make progress legible | FE eng manager |
|
||||
| R7 | Flag debt accumulates as flags gate incomplete work on trunk | Med | Low | Low | Review-for-deletion date on every flag; monthly cleanup | FE leads |
|
||||
|
||||
Doing nothing carries its own critical risks (drift compounds, the next
|
||||
missed backport ships another regression, rotation burnout continues); the
|
||||
status quo is not the safe option.
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. Cadence of the core train once the drift SLO lands: stay biweekly or move
|
||||
to weekly? At current velocity the under-100 batch target in section 9 is
|
||||
reachable only with weekly trains, so this question blocks that one
|
||||
metric's horizon. (Rotation owner plus core release stakeholders; blocking
|
||||
for the batch-size target, non-blocking for everything else.)
|
||||
2. Should the prod pointer cover core GA and desktop as refs too
|
||||
(`deployed/core-ga`), or is PyPI plus the pin authoritative enough?
|
||||
(Frontend leads; non-blocking.)
|
||||
3. Who owns the standing QA gate criteria documents long-term as QA staffing
|
||||
changes? (QA plus FE eng manager; blocking for Phase 0 sign-off.)
|
||||
4. Does the org-admin limitation on separate go-live approvers (surfaced
|
||||
during FE-1176) need resolution before GitHub Environments become the prod
|
||||
gate? (DevOps plus org admins; blocking for P1 item 8.)
|
||||
|
||||
## 14. References
|
||||
|
||||
Internal: `docs/release-process.md` (the operational runbook of record,
|
||||
updated as phases land); the shipping-speed initiative and its release-gate
|
||||
and canary design docs; the release rotation runbook; the
|
||||
premature-version-bump post-mortem; FE-713, FE-1176, FE-1282, FE-602/BE-800;
|
||||
the draft 4-tier proposal this document reviews.
|
||||
|
||||
External: see section 5 source list.
|
||||
@@ -10,11 +10,13 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
|
||||
...LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
)
|
||||
)
|
||||
),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
|
||||
])
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
|
||||
51
apps/website/e2e/brand.spec.ts
Normal file
51
apps/website/e2e/brand.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Brand portal @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/brand')
|
||||
})
|
||||
|
||||
test('renders each brand guideline section', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'One mark, many dimensions.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Every color earns its place.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Precise, never cute.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Trademark guidelines.' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows all four marks', async ({ page }) => {
|
||||
const logos = page.locator('#logos')
|
||||
for (const name of [
|
||||
'Core Logo',
|
||||
'Logomark',
|
||||
'Icon',
|
||||
'Amplified Logomark'
|
||||
]) {
|
||||
await expect(logos.getByText(name, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('the hero ctas open the gated guidelines and the logo bundle', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'View brand guidelines' })
|
||||
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Download logos' })
|
||||
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
|
||||
})
|
||||
})
|
||||
112
apps/website/e2e/mcp.spec.ts
Normal file
112
apps/website/e2e/mcp.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
|
||||
|
||||
test.describe('MCP page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/mcp')
|
||||
})
|
||||
|
||||
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
|
||||
page
|
||||
}) => {
|
||||
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
|
||||
await expect(installLinks).toHaveCount(2)
|
||||
for (const link of await installLinks.all()) {
|
||||
await expect(link).toHaveAttribute('href', '#setup')
|
||||
}
|
||||
})
|
||||
|
||||
test('setup section shows both install options', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('heading', {
|
||||
name: 'Ask your agent to install Comfy MCP'
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: 'Install manually' })
|
||||
).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('client tabs swap install instructions', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
|
||||
|
||||
// Claude Code is the default tab and carries the CLI command
|
||||
await expect(
|
||||
setup.getByRole('tab', { name: 'Claude Code' })
|
||||
).toHaveAttribute('data-state', 'active')
|
||||
await expect(activePanel).toContainText(
|
||||
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
|
||||
)
|
||||
|
||||
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
|
||||
await expect(activePanel).toContainText('Add custom connector')
|
||||
|
||||
await setup.getByRole('tab', { name: 'Cursor' }).click()
|
||||
await expect(activePanel).toContainText('X-API-Key')
|
||||
await expect(
|
||||
activePanel.getByRole('link', { name: 'platform.comfy.org' })
|
||||
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
|
||||
|
||||
await setup.getByRole('tab', { name: 'Codex' }).click()
|
||||
await expect(activePanel).toContainText(
|
||||
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
|
||||
)
|
||||
})
|
||||
|
||||
test('skills plugin link lives in the agent option card', async ({
|
||||
page
|
||||
}) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('link', { name: 'View on GitHub' })
|
||||
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
|
||||
})
|
||||
|
||||
test('capabilities section shows all six tool cards', async ({ page }) => {
|
||||
for (const title of [
|
||||
'Generate anything',
|
||||
'Search the ecosystem',
|
||||
'Run real workflows',
|
||||
'Direct any model',
|
||||
'Generate in batches',
|
||||
'Ship it as an app'
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: title, exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('FAQ lists nine questions and autolinks the server URL', async ({
|
||||
page
|
||||
}) => {
|
||||
const triggers = page.locator('[id^="faq-trigger-"]')
|
||||
await triggers.first().scrollIntoViewIfNeeded()
|
||||
await expect(triggers).toHaveCount(9)
|
||||
|
||||
await page.getByRole('button', { name: "What's the server URL?" }).click()
|
||||
await expect(
|
||||
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
|
||||
).toHaveAttribute('href', MCP_ENDPOINT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('MCP page zh-CN @smoke', () => {
|
||||
test('setup section renders localized options', async ({ page }) => {
|
||||
await page.goto('/zh-CN/mcp')
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(setup.getByText('方式一')).toBeVisible()
|
||||
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
4
apps/website/public/icons/comfyicon.svg
Normal file
4
apps/website/public/icons/comfyicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="275" height="275" viewBox="0 0 275 275" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="274.66" height="274.66" rx="63.5555" fill="#211927"/>
|
||||
<path d="M177.456 174.409C177.713 173.538 177.854 172.621 177.854 171.656C177.854 166.313 173.546 161.983 168.232 161.983H125.108C122.791 162.006 120.894 160.124 120.894 157.794C120.894 157.37 120.965 156.97 121.058 156.594L132.67 115.926C133.162 114.137 134.801 112.819 136.72 112.819L180.008 112.772C189.138 112.772 196.84 106.582 199.158 98.1335L205.666 75.4696C205.877 74.6695 205.994 73.7987 205.994 72.9279C205.994 67.6091 201.71 63.3022 196.419 63.3022H144.048C134.965 63.3022 127.286 69.4448 124.921 77.7996L120.52 93.2618C120.005 95.0269 118.389 96.3213 116.47 96.3213H103.898C94.8846 96.3213 87.276 102.346 84.8412 110.607L69.0152 166.172C68.7811 166.996 68.6641 167.89 68.6641 168.784C68.6641 174.127 72.9717 178.457 78.2861 178.457H90.6472C92.9649 178.457 94.8612 180.34 94.8612 182.693C94.8612 183.093 94.8144 183.494 94.6973 183.87L90.3194 199.191C90.1087 200.015 89.9683 200.862 89.9683 201.733C89.9683 207.052 94.2525 211.359 99.5434 211.359L151.938 211.312C161.045 211.312 168.724 205.145 171.065 196.744L177.432 174.433L177.456 174.409Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
@@ -9,6 +9,31 @@ const { faqs } = defineProps<{
|
||||
faqs: readonly Faq[]
|
||||
}>()
|
||||
|
||||
type AnswerPart = { type: 'text' | 'link'; value: string }
|
||||
|
||||
function parseAnswer(answer: string): AnswerPart[] {
|
||||
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
|
||||
const parts: AnswerPart[] = []
|
||||
let lastIndex = 0
|
||||
for (const match of answer.matchAll(urlPattern)) {
|
||||
const start = match.index ?? 0
|
||||
const url = match[0].replace(/[.,;:]+$/, '')
|
||||
if (start > lastIndex) {
|
||||
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
|
||||
}
|
||||
parts.push({ type: 'link', value: url })
|
||||
lastIndex = start + url.length
|
||||
}
|
||||
if (lastIndex < answer.length) {
|
||||
parts.push({ type: 'text', value: answer.slice(lastIndex) })
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
const parsedFaqs = computed(() =>
|
||||
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
|
||||
)
|
||||
|
||||
const expanded = reactive<boolean[]>(faqs.map(() => false))
|
||||
|
||||
watch(
|
||||
@@ -40,7 +65,7 @@ function toggle(index: number) {
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
v-for="(faq, index) in parsedFaqs"
|
||||
:key="faq.id"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
@@ -83,8 +108,23 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
<p
|
||||
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
|
||||
>
|
||||
<template
|
||||
v-for="(part, partIndex) in faq.answerParts"
|
||||
:key="partIndex"
|
||||
>
|
||||
<a
|
||||
v-if="part.type === 'link'"
|
||||
:href="part.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ part.value }}</a
|
||||
>
|
||||
<template v-else>{{ part.value }}</template>
|
||||
</template>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
type CardAction =
|
||||
| {
|
||||
type: 'link'
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank'
|
||||
icon?: Component
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
export interface FeatureCard {
|
||||
id: string
|
||||
label?: string
|
||||
title: string
|
||||
description: string
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<ColumnCount, string> = {
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
4: 'lg:grid-cols-4'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<p
|
||||
v-if="card.label"
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ card.label }}
|
||||
</p>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
|
||||
card.label && 'mt-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="card.action" class="mt-6">
|
||||
<Button
|
||||
v-if="card.action.type === 'link'"
|
||||
as="a"
|
||||
:href="card.action.href"
|
||||
:target="card.action.target"
|
||||
:rel="
|
||||
card.action.target === '_blank'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
"
|
||||
:variant="card.action.variant ?? 'outline'"
|
||||
:append-icon="card.action.icon"
|
||||
>
|
||||
{{ card.action.label }}
|
||||
</Button>
|
||||
<CopyableField
|
||||
v-else
|
||||
:value="card.action.value"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@ import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import type { VideoTrack } from '../common/VideoPlayer.vue'
|
||||
|
||||
type RowMedia =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
@@ -20,6 +20,7 @@ type RowMedia =
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
@@ -58,7 +59,7 @@ const {
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12',
|
||||
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
|
||||
)
|
||||
"
|
||||
@@ -72,10 +73,11 @@ const {
|
||||
</div>
|
||||
|
||||
<!-- Media: image or video -->
|
||||
<!-- 620/364 and w-155 (620px) match the card media asset dimensions -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-1 flex lg:w-1/2',
|
||||
'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0',
|
||||
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
|
||||
)
|
||||
"
|
||||
@@ -86,7 +88,12 @@ const {
|
||||
:alt="row.media.alt ?? row.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-4xl object-cover"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full rounded-4xl',
|
||||
row.media.fit === 'contain' ? 'object-contain' : 'object-cover'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<VideoPlayer
|
||||
v-else
|
||||
@@ -99,7 +106,13 @@ const {
|
||||
:loop="row.media.loop"
|
||||
:minimal="row.media.minimal"
|
||||
:hide-controls="row.media.hideControls"
|
||||
class="w-full"
|
||||
:fit="row.media.fit"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full',
|
||||
row.media.fit === 'contain' && 'bg-transparent'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
const { resources } = defineProps<{
|
||||
resources: { label: string; href: string; display: string }[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
|
||||
<li v-for="resource in resources" :key="resource.href">
|
||||
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
|
||||
<span class="text-primary-warm-gray"> — </span>
|
||||
<a
|
||||
:href="resource.href"
|
||||
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{{ resource.display }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -85,6 +85,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
links: [
|
||||
{ label: t('footer.about', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.brand', locale), href: routes.brand },
|
||||
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
|
||||
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
|
||||
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
whenever
|
||||
} from '@vueuse/core'
|
||||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
@@ -30,7 +31,9 @@ const {
|
||||
autoplay = false,
|
||||
loop = false,
|
||||
minimal = false,
|
||||
hideControls = false
|
||||
hideControls = false,
|
||||
fit = 'cover',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
src?: string
|
||||
@@ -40,6 +43,8 @@ const {
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
|
||||
@@ -189,7 +194,12 @@ function toggleFullscreen() {
|
||||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
|
||||
:class="
|
||||
cn(
|
||||
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
|
||||
className
|
||||
)
|
||||
"
|
||||
@pointermove="showControls"
|
||||
@pointerdown="showControls"
|
||||
@focusin="showControls"
|
||||
@@ -197,7 +207,9 @@ function toggleFullscreen() {
|
||||
<video
|
||||
v-if="src"
|
||||
ref="videoEl"
|
||||
class="size-full object-cover"
|
||||
:class="
|
||||
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
|
||||
"
|
||||
:src
|
||||
:poster
|
||||
:preload="autoplay ? 'auto' : 'metadata'"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { href, label } = defineProps<{
|
||||
href: string
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ label }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,12 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
const { title } = defineProps<{ title: string }>()
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { title, class: className } = defineProps<{
|
||||
title: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
|
||||
>
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
|
||||
<h1
|
||||
:class="
|
||||
cn(
|
||||
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
@@ -20,6 +20,8 @@ export const buttonVariants = cva(
|
||||
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
|
||||
underlineLink:
|
||||
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
|
||||
inline:
|
||||
'text-primary-comfy-yellow inline h-auto rounded-none p-0 align-baseline text-sm font-normal tracking-normal whitespace-normal hover:opacity-90 [&>span]:top-0 [&>span]:underline',
|
||||
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
|
||||
navMuted:
|
||||
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
|
||||
|
||||
@@ -21,7 +21,8 @@ const baseRoutes = {
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models',
|
||||
mcp: '/mcp'
|
||||
mcp: '/mcp',
|
||||
brand: '/brand'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -87,6 +88,7 @@ export const externalLinks = {
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpEndpoint: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
import { BRAND_ASSETS_ZIP } from './brandAssets'
|
||||
|
||||
interface AffiliateBrandAsset {
|
||||
id: string
|
||||
title: LocalizedText
|
||||
@@ -7,9 +9,6 @@ interface AffiliateBrandAsset {
|
||||
preview: string
|
||||
}
|
||||
|
||||
const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
|
||||
{
|
||||
id: 'core-logo',
|
||||
|
||||
9
apps/website/src/data/brandAssets.ts
Normal file
9
apps/website/src/data/brandAssets.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Shared brand download URLs served from the media bucket, used by both the
|
||||
// affiliate page and the brand portal.
|
||||
export const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
// Brand guidelines live in Google Drive, shared to Comfy Org only, so Google
|
||||
// enforces the comfy.org sign-in. Opened in a new tab rather than downloaded.
|
||||
export const BRAND_GUIDELINES_PDF =
|
||||
'https://drive.google.com/file/d/1EDt03JTfF_nbbY_H2n67aaUj6k11v3bS/view'
|
||||
91
apps/website/src/data/brandColors.ts
Normal file
91
apps/website/src/data/brandColors.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
interface BrandColor {
|
||||
name: string
|
||||
hex: string
|
||||
rgb: string
|
||||
hsl: string
|
||||
cmyk: string
|
||||
swatchClass: string
|
||||
textClass: string
|
||||
wide?: boolean
|
||||
border?: boolean
|
||||
}
|
||||
|
||||
export const brandColors: readonly BrandColor[] = [
|
||||
{
|
||||
name: 'Comfy Yellow',
|
||||
hex: '#F2FF59',
|
||||
rgb: '242, 255, 89',
|
||||
hsl: '65, 100, 67',
|
||||
cmyk: '5, 0, 65, 0',
|
||||
swatchClass: 'bg-primary-comfy-yellow',
|
||||
textClass: 'text-primary-comfy-ink',
|
||||
wide: true
|
||||
},
|
||||
{
|
||||
name: 'Comfy Ink',
|
||||
hex: '#211927',
|
||||
rgb: '33, 25, 39',
|
||||
hsl: '274, 22, 13',
|
||||
cmyk: '15, 36, 0, 85',
|
||||
swatchClass: 'bg-primary-comfy-ink',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Comfy Canvas',
|
||||
hex: '#C2BFB9',
|
||||
rgb: '194, 191, 185',
|
||||
hsl: '40, 7, 74',
|
||||
cmyk: '0, 2, 5, 24',
|
||||
swatchClass: 'bg-primary-comfy-canvas',
|
||||
textClass: 'text-primary-comfy-ink'
|
||||
},
|
||||
{
|
||||
name: 'Comfy Plum',
|
||||
hex: '#49378B',
|
||||
rgb: '73, 55, 139',
|
||||
hsl: '253, 43, 38',
|
||||
cmyk: '47, 60, 0, 45',
|
||||
swatchClass: 'bg-primary-comfy-plum',
|
||||
textClass: 'text-primary-comfy-canvas'
|
||||
},
|
||||
{
|
||||
name: 'Warm White',
|
||||
hex: '#F0EFED',
|
||||
rgb: '240, 239, 237',
|
||||
hsl: '40, 9, 94',
|
||||
cmyk: '0, 0, 1, 6',
|
||||
swatchClass: 'bg-primary-warm-white',
|
||||
textClass: 'text-primary-comfy-ink',
|
||||
wide: true
|
||||
},
|
||||
{
|
||||
name: 'Warm Gray',
|
||||
hex: '#7E7C78',
|
||||
rgb: '126, 124, 120',
|
||||
hsl: '40, 2, 48',
|
||||
cmyk: '0, 2, 5, 51',
|
||||
swatchClass: 'bg-primary-warm-gray',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Cool Gray',
|
||||
hex: '#3C3C3C',
|
||||
rgb: '60, 60, 60',
|
||||
hsl: '0, 0, 24',
|
||||
cmyk: '0, 0, 0, 76',
|
||||
swatchClass: 'bg-secondary-cool-gray',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Mauve',
|
||||
hex: '#4D3762',
|
||||
rgb: '77, 55, 98',
|
||||
hsl: '271, 28, 30',
|
||||
cmyk: '21, 44, 0, 62',
|
||||
swatchClass: 'bg-secondary-mauve',
|
||||
textClass: 'text-primary-warm-white'
|
||||
}
|
||||
] as const
|
||||
@@ -1864,10 +1864,26 @@ const translations = {
|
||||
'zh-CN':
|
||||
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
|
||||
},
|
||||
'mcp.hero.demoPrompt': {
|
||||
'mcp.hero.demoPromptMoodboard': {
|
||||
en: 'turn the brief in this email into a 6-up moodboard',
|
||||
'zh-CN': '把这封邮件里的需求做成六宫格情绪板'
|
||||
},
|
||||
'mcp.hero.demoPromptConcepts': {
|
||||
en: 'sketch three concept frames for the launch page',
|
||||
'zh-CN': '为发布页画三张概念稿'
|
||||
},
|
||||
'mcp.hero.demoPromptKeyart': {
|
||||
en: "match this frame's palette, make the hero key art",
|
||||
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
|
||||
},
|
||||
'mcp.hero.demoPromptPbr': {
|
||||
en: 'make a tileable asphalt PBR material, all 5 maps',
|
||||
'zh-CN': '生成可平铺的沥青 PBR 材质,共 5 张贴图'
|
||||
},
|
||||
'mcp.hero.demoPromptUpscale': {
|
||||
en: 'upscale the neon kaiju shot to 4K',
|
||||
'zh-CN': '把霓虹怪兽画面放大到 4K'
|
||||
},
|
||||
'mcp.hero.viewDocs': {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
@@ -1876,10 +1892,6 @@ const translations = {
|
||||
en: 'INSTALL MCP',
|
||||
'zh-CN': '安装 MCP'
|
||||
},
|
||||
'mcp.hero.runWorkflow': {
|
||||
en: 'RUN A WORKFLOW',
|
||||
'zh-CN': '运行工作流'
|
||||
},
|
||||
'mcp.hero.demoGenerate': {
|
||||
en: 'GENERATE',
|
||||
'zh-CN': '生成'
|
||||
@@ -1897,60 +1909,90 @@ const translations = {
|
||||
'zh-CN': '放大图像'
|
||||
},
|
||||
|
||||
// MCP – SetupStepsSection
|
||||
// MCP – SetupSection
|
||||
'mcp.setup.label': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'mcp.setup.heading': {
|
||||
en: 'Set up Comfy MCP in three steps',
|
||||
'zh-CN': '三步完成 Comfy MCP 配置'
|
||||
en: 'Set up Comfy MCP',
|
||||
'zh-CN': '配置 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.subtitle': {
|
||||
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
en: 'Two ways to connect: ask your agent to install it, or add the server yourself. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
'zh-CN':
|
||||
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
'mcp.setup.option1.label': { en: 'OPTION 1', 'zh-CN': '方式一' },
|
||||
'mcp.setup.option1.title': {
|
||||
en: 'Ask your agent to install Comfy MCP',
|
||||
'zh-CN': '让你的智能体安装 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.step1.command': {
|
||||
'mcp.setup.option1.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
'mcp.setup.option1.description': {
|
||||
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'zh-CN':
|
||||
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Or add it by hand',
|
||||
'zh-CN': '或手动添加'
|
||||
'mcp.setup.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
|
||||
'mcp.setup.option2.title': {
|
||||
en: 'Install manually',
|
||||
'zh-CN': '手动安装'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
|
||||
'mcp.setup.option2.description': {
|
||||
en: 'Prefer manual setup? Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
|
||||
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
'zh-CN': 'COMFY CLOUD MCP 文档'
|
||||
'mcp.setup.option2.tabsLabel': {
|
||||
en: 'Pick your client',
|
||||
'zh-CN': '选择你的客户端'
|
||||
},
|
||||
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
|
||||
'mcp.setup.step3.title': {
|
||||
en: 'Connect and sign in',
|
||||
'zh-CN': '连接并登录'
|
||||
'mcp.setup.clients.claudeCode.step': {
|
||||
en: 'Run this in your terminal, then use /mcp to pick comfy-cloud and authenticate.',
|
||||
'zh-CN': '在终端运行以下命令,然后通过 /mcp 选择 comfy-cloud 并完成认证。'
|
||||
},
|
||||
'mcp.setup.step3.description': {
|
||||
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
|
||||
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
|
||||
'mcp.setup.clients.claudeDesktop.step': {
|
||||
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
|
||||
'zh-CN':
|
||||
'点击侧边栏的 Customize,进入 Connectors,选择添加自定义连接器,粘贴上方 URL 并登录。'
|
||||
},
|
||||
'mcp.setup.step3.cta': {
|
||||
en: 'COMFY CLOUD SKILLS',
|
||||
'zh-CN': 'COMFY CLOUD 技能'
|
||||
'mcp.setup.clients.cursor.step': {
|
||||
en: 'Add the URL above to ~/.cursor/mcp.json with an X-API-Key header. Create your key at ',
|
||||
'zh-CN':
|
||||
'将上方 URL 添加到 ~/.cursor/mcp.json,并附带 X-API-Key 请求头。在此创建密钥:'
|
||||
},
|
||||
'mcp.setup.clients.cursor.linkLabel': {
|
||||
en: 'platform.comfy.org',
|
||||
'zh-CN': 'platform.comfy.org'
|
||||
},
|
||||
'mcp.setup.clients.codex.step': {
|
||||
en: 'Run this in your terminal, then codex mcp login comfy-cloud to sign in.',
|
||||
'zh-CN': '在终端运行以下命令,然后执行 codex mcp login comfy-cloud 登录。'
|
||||
},
|
||||
'mcp.setup.clients.other.name': {
|
||||
en: 'Other clients',
|
||||
'zh-CN': '其他客户端'
|
||||
},
|
||||
'mcp.setup.clients.other.step': {
|
||||
en: 'Add the URL above as a remote MCP server. No OAuth in your client? Use an X-API-Key header instead. Full walkthroughs live in the ',
|
||||
'zh-CN':
|
||||
'将上方 URL 添加为远程 MCP 服务器。客户端不支持 OAuth?改用 X-API-Key 请求头。完整教程见'
|
||||
},
|
||||
'mcp.setup.clients.other.linkLabel': {
|
||||
en: 'setup docs',
|
||||
'zh-CN': '设置文档'
|
||||
},
|
||||
'mcp.setup.skillsNote': {
|
||||
en: 'Using Claude Code? The Comfy skills plugin adds ready-made slash commands. ',
|
||||
'zh-CN': '在用 Claude Code?Comfy 技能插件提供现成的斜杠命令。'
|
||||
},
|
||||
'mcp.setup.skillsLink': {
|
||||
en: 'View on GitHub',
|
||||
'zh-CN': '在 GitHub 上查看'
|
||||
},
|
||||
|
||||
// MCP – WhyBuildSection
|
||||
@@ -1971,9 +2013,9 @@ const translations = {
|
||||
'zh-CN': '开放协议,\n任意客户端。'
|
||||
},
|
||||
'mcp.why.1.description': {
|
||||
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
|
||||
en: 'MCP is an open standard, so any MCP-compatible client can connect. Claude Code, Claude Desktop, and Codex sign in with OAuth; every other agent connects with an API key.',
|
||||
'zh-CN':
|
||||
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop,更多客户端即将推出。'
|
||||
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。Claude Code、Claude Desktop 和 Codex 通过 OAuth 登录,其他智能体使用 API 密钥连接。'
|
||||
},
|
||||
'mcp.why.2.title': {
|
||||
en: 'The full engine,\nnot a sandbox.',
|
||||
@@ -2037,14 +2079,53 @@ const translations = {
|
||||
'zh-CN': '运行真实工作流'
|
||||
},
|
||||
'mcp.tools.3.description': {
|
||||
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
|
||||
en: 'Submit graphs, track jobs, and pull outputs back. Save and share workflows, reuse a saved one, or open any run on the ComfyUI canvas — the full engine, driven by tool calls.',
|
||||
'zh-CN':
|
||||
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
|
||||
'提交计算图、跟踪任务并取回输出。保存和分享工作流,复用已保存的工作流,或在 ComfyUI 画布上打开任意运行——完整的引擎,由工具调用驱动。'
|
||||
},
|
||||
'mcp.tools.3.alt': {
|
||||
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
|
||||
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
|
||||
},
|
||||
'mcp.tools.4.title': {
|
||||
en: 'Direct any model',
|
||||
'zh-CN': '直接调用任意模型'
|
||||
},
|
||||
'mcp.tools.4.description': {
|
||||
en: 'Kling, Veo, Seedance, Flux, GPT-Image, Nano Banana, and ElevenLabs. Closed partner APIs and open-source models, reached through one set of tools.',
|
||||
'zh-CN':
|
||||
'Kling、Veo、Seedance、Flux、GPT-Image、Nano Banana 和 ElevenLabs。封闭的合作伙伴 API 与开源模型,通过同一套工具即可调用。'
|
||||
},
|
||||
'mcp.tools.4.alt': {
|
||||
en: 'Comfy MCP directing closed partner APIs and open-source models through one set of tools',
|
||||
'zh-CN': 'Comfy MCP 通过同一套工具调用封闭合作伙伴 API 和开源模型'
|
||||
},
|
||||
'mcp.tools.5.title': {
|
||||
en: 'Generate in batches',
|
||||
'zh-CN': '批量生成'
|
||||
},
|
||||
'mcp.tools.5.description': {
|
||||
en: 'Stack a batch on the Queue, track it, and pull back every output. Dozens of runs from a single call.',
|
||||
'zh-CN':
|
||||
'将一批任务加入队列,跟踪进度,并取回每一个输出。一次调用即可完成数十次运行。'
|
||||
},
|
||||
'mcp.tools.5.alt': {
|
||||
en: 'Comfy MCP stacking a batch on the Queue and pulling back every output',
|
||||
'zh-CN': 'Comfy MCP 将一批任务加入队列并取回每个输出'
|
||||
},
|
||||
'mcp.tools.6.title': {
|
||||
en: 'Ship it as an app',
|
||||
'zh-CN': '作为应用发布'
|
||||
},
|
||||
'mcp.tools.6.description': {
|
||||
en: 'Turn any workflow into an app with a shareable URL. Collaborators run it in the browser — only the inputs you expose, nothing to install.',
|
||||
'zh-CN':
|
||||
'将任意工作流变成带可分享链接的应用。协作者在浏览器中运行——只暴露你开放的输入,无需安装任何东西。'
|
||||
},
|
||||
'mcp.tools.6.alt': {
|
||||
en: 'Comfy MCP turning a workflow into a shareable browser app',
|
||||
'zh-CN': 'Comfy MCP 将工作流变成可在浏览器中分享的应用'
|
||||
},
|
||||
|
||||
// MCP – HowItWorksSection
|
||||
'mcp.howItWorks.heading': {
|
||||
@@ -2091,71 +2172,81 @@ const translations = {
|
||||
'zh-CN': '支持哪些客户端?'
|
||||
},
|
||||
'mcp.faq.1.a': {
|
||||
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
|
||||
en: "For Claude Code, Claude Desktop, or Codex, add https://cloud.comfy.org/mcp as a custom connector or remote MCP server in any client, then sign in when prompted.\nFor clients that don't support OAuth, connect with a Comfy API key. Send the docs https://docs.comfy.org/agent-tools/cloud to your agent and it will figure out the installation for you.",
|
||||
'zh-CN':
|
||||
'目前支持 Claude Code 和 Claude Desktop,均通过 OAuth 登录。更多客户端的支持即将推出。'
|
||||
'对于 Claude Code、Claude Desktop 或 Codex,在任意客户端中将 https://cloud.comfy.org/mcp 添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。\n对于不支持 OAuth 的客户端,请使用 Comfy API 密钥连接。将文档 https://docs.comfy.org/agent-tools/cloud 发送给你的智能体,它会为你完成安装。'
|
||||
},
|
||||
'mcp.faq.2.q': {
|
||||
en: "What's the server URL?",
|
||||
'zh-CN': '服务器 URL 是什么?'
|
||||
},
|
||||
'mcp.faq.2.a': {
|
||||
en: 'https://cloud.comfy.org/mcp — add it as a custom connector or remote MCP server in any client, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'https://cloud.comfy.org/mcp——在任意客户端中将它添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。'
|
||||
},
|
||||
'mcp.faq.3.q': {
|
||||
en: 'Do I need an API key?',
|
||||
'zh-CN': '我需要 API 密钥吗?'
|
||||
},
|
||||
'mcp.faq.2.a': {
|
||||
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
|
||||
'zh-CN':
|
||||
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
|
||||
},
|
||||
'mcp.faq.3.q': {
|
||||
en: 'Do the slash commands work in Claude Desktop?',
|
||||
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
|
||||
},
|
||||
'mcp.faq.3.a': {
|
||||
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
|
||||
en: 'Not for Claude Code, Claude Desktop, or Codex. You need a Comfy API key for Cursor, Hermes, and OpenClaw for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
|
||||
'zh-CN':
|
||||
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
|
||||
'Claude Code、Claude Desktop 和 Codex 不需要。Cursor、Hermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud,你的智能体就会为你完成安装。'
|
||||
},
|
||||
'mcp.faq.4.q': {
|
||||
en: "The sign-in didn't open a browser.",
|
||||
'zh-CN': '登录时没有打开浏览器。'
|
||||
en: 'Does it cost anything?',
|
||||
'zh-CN': '需要付费吗?'
|
||||
},
|
||||
'mcp.faq.4.a': {
|
||||
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
|
||||
en: "Connecting is free with a Comfy account, and searching models, nodes, and templates doesn't cost credits. Running a generation uses Comfy Cloud credits and needs a subscription or credit balance. Your agent confirms with you before it spends.",
|
||||
'zh-CN':
|
||||
'在 Claude Code 中,运行 /mcp,选择 comfy-cloud,然后选择 Authenticate(授权)。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
|
||||
'使用 Comfy 账户连接是免费的,搜索模型、节点和模板也不消耗积分。运行生成会使用 Comfy Cloud 积分,需要订阅或积分余额。智能体在消费前会先与你确认。'
|
||||
},
|
||||
'mcp.faq.5.q': {
|
||||
en: 'How do I connect in Claude Code?',
|
||||
'zh-CN': '如何在 Claude Code 中连接?'
|
||||
en: 'Can I use it with my local ComfyUI?',
|
||||
'zh-CN': '可以配合我的本地 ComfyUI 使用吗?'
|
||||
},
|
||||
'mcp.faq.5.a': {
|
||||
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
|
||||
en: 'Coming soon. Today, to drive a local ComfyUI, you can use comfy-cli: https://github.com/Comfy-Org/comfy-cli',
|
||||
'zh-CN':
|
||||
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate(授权)。一步即可添加连接和斜杠命令。'
|
||||
'即将推出。目前,若要操作本地 ComfyUI,你可以使用 comfy-cli:https://github.com/Comfy-Org/comfy-cli'
|
||||
},
|
||||
'mcp.faq.6.q': {
|
||||
en: "What's the server URL for Claude Desktop?",
|
||||
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
|
||||
},
|
||||
'mcp.faq.6.a': {
|
||||
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
|
||||
},
|
||||
'mcp.faq.7.q': {
|
||||
en: 'What can my agent do once connected?',
|
||||
'zh-CN': '连接后我的智能体能做什么?'
|
||||
},
|
||||
'mcp.faq.7.a': {
|
||||
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
|
||||
'mcp.faq.6.a': {
|
||||
en: "• Generate images, video, audio, and 3D — including all open-source workflows and partner models like Seedance, GPT-Image, Nano Banana, and Kling\n• Build, edit, and run workflows; save and re-run workflows\n• Run and read in large batches\n• Search models, nodes, and template workflows\n• Read and execute shared workflow URLs\n• Upload and download assets for you\n\nEverything is now in natural language. No nodes, no downloads, no GPU, no node graphs if you don't want them.",
|
||||
'zh-CN':
|
||||
'生成图像、视频、音频和 3D;搜索模型、节点和模板;并运行 ComfyUI 工作流——全部在对话中完成。'
|
||||
'• 生成图像、视频、音频和 3D——包括所有开源工作流以及 Seedance、GPT-Image、Nano Banana 和 Kling 等合作伙伴模型\n• 构建、编辑和运行工作流;保存并重新运行工作流\n• 大批量运行和读取\n• 搜索模型、节点和模板工作流\n• 读取并执行分享的工作流链接\n• 为你上传和下载资产\n\n现在一切都用自然语言完成。如果你愿意,无需节点、无需下载、无需 GPU、无需节点图。'
|
||||
},
|
||||
'mcp.faq.7.q': {
|
||||
en: 'Where do my outputs go?',
|
||||
'zh-CN': '我的输出会保存到哪里?'
|
||||
},
|
||||
'mcp.faq.7.a': {
|
||||
en: 'Into your Comfy Cloud asset library, so you can reuse, remix, and share them — and open any run on the canvas to keep editing. You can also ask your agent to download the assets locally for you.',
|
||||
'zh-CN':
|
||||
'保存到你的 Comfy Cloud 资产库,你可以复用、二次创作和分享——还能在画布上打开任意运行继续编辑。你也可以让智能体把资产下载到本地。'
|
||||
},
|
||||
'mcp.faq.8.q': {
|
||||
en: 'Do slash commands work in Claude Desktop?',
|
||||
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
|
||||
},
|
||||
'mcp.faq.8.a': {
|
||||
en: 'No. They ship with the Claude Code comfy-cloud plugin. Desktop connects to the same MCP server, so every tool works; just ask in plain language.',
|
||||
'zh-CN':
|
||||
'不可以。斜杠命令随 Claude Code 的 comfy-cloud 插件一起提供。Claude Desktop 连接的是同一个 MCP 服务器,因此所有工具都能使用;直接用自然语言提问即可。'
|
||||
},
|
||||
'mcp.faq.9.q': {
|
||||
en: 'Is it generally available?',
|
||||
'zh-CN': '现已正式发布了吗?'
|
||||
},
|
||||
'mcp.faq.8.a': {
|
||||
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
|
||||
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
|
||||
'mcp.faq.9.a': {
|
||||
en: 'Yes. Comfy Cloud MCP is in open beta and available to everyone with a Comfy account.',
|
||||
'zh-CN':
|
||||
'是的。Comfy Cloud MCP 目前处于公开测试阶段,任何拥有 Comfy 账户的人都可以使用。'
|
||||
},
|
||||
|
||||
// SiteNav
|
||||
@@ -2181,6 +2272,7 @@ const translations = {
|
||||
'nav.youtube': { en: 'YouTube', 'zh-CN': 'YouTube' },
|
||||
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'nav.brand': { en: 'Brand', 'zh-CN': '品牌' },
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
@@ -4446,6 +4538,161 @@ const translations = {
|
||||
'launches.section.title': {
|
||||
en: 'Latest Launches',
|
||||
'zh-CN': '最新发布'
|
||||
},
|
||||
|
||||
// Brand Portal page (/brand)
|
||||
'brand.page.title': {
|
||||
en: 'Brand — Comfy',
|
||||
'zh-CN': '品牌 — Comfy'
|
||||
},
|
||||
'brand.page.description': {
|
||||
en: 'The Comfy brand portal: logos, color, typography, and voice. Everything you need to build something that looks and sounds like Comfy.',
|
||||
'zh-CN':
|
||||
'Comfy 品牌门户:标志、色彩、字体与语调。打造与 Comfy 观感一致、表达一致所需的一切。'
|
||||
},
|
||||
'brand.hero.label': {
|
||||
en: 'Brand Portal',
|
||||
'zh-CN': '品牌门户'
|
||||
},
|
||||
'brand.hero.heading': {
|
||||
en: 'Create with ComfyUI',
|
||||
'zh-CN': '用 ComfyUI 创作'
|
||||
},
|
||||
'brand.hero.subheading': {
|
||||
en: 'Logo, color, type, and voice. Everything you need to build something that looks and sounds like us.',
|
||||
'zh-CN': '标志、色彩、字体与语调。打造与我们观感一致、表达一致所需的一切。'
|
||||
},
|
||||
'brand.hero.viewGuidelines': {
|
||||
en: 'View brand guidelines',
|
||||
'zh-CN': '查看品牌规范'
|
||||
},
|
||||
'brand.hero.downloadLogos': {
|
||||
en: 'Download logos',
|
||||
'zh-CN': '下载标志'
|
||||
},
|
||||
'brand.logos.heading': {
|
||||
en: 'One mark, many dimensions.',
|
||||
'zh-CN': '一个标志,多种维度。'
|
||||
},
|
||||
'brand.logos.subheading': {
|
||||
en: 'Logos come in light and dark options. Use as provided. Do not distort, recolor, or outline. Make sure the logo is legible against its background.',
|
||||
'zh-CN':
|
||||
'标志提供浅色和深色两种版本。请按原样使用,不要变形、改色或描边。确保标志在其背景上清晰可辨。'
|
||||
},
|
||||
'brand.colors.heading': {
|
||||
en: 'Every color earns its place.',
|
||||
'zh-CN': '每种颜色都各得其所。'
|
||||
},
|
||||
'brand.colors.subheading': {
|
||||
en: 'Our color palette helps build brand recognition. When people think of Comfy, we want them to associate it with the following colors.',
|
||||
'zh-CN':
|
||||
'我们的调色板有助于建立品牌辨识度。当人们想到 Comfy 时,我们希望他们联想到以下这些颜色。'
|
||||
},
|
||||
'brand.colors.copy': {
|
||||
en: 'Copy',
|
||||
'zh-CN': '复制'
|
||||
},
|
||||
'brand.colors.copied': {
|
||||
en: 'Copied',
|
||||
'zh-CN': '已复制'
|
||||
},
|
||||
'brand.voice.heading': {
|
||||
en: 'Precise, never cute.',
|
||||
'zh-CN': '精准,绝不卖弄。'
|
||||
},
|
||||
'brand.voice.direct.title': {
|
||||
en: 'Direct',
|
||||
'zh-CN': '直接'
|
||||
},
|
||||
'brand.voice.direct.body': {
|
||||
en: 'We state things. We don’t hedge, qualify, or suggest. Short sentences. Active voice. One idea at a time.',
|
||||
'zh-CN':
|
||||
'我们直陈其事。不含糊、不设限、不暗示。短句。主动语态。一次只讲一个观点。'
|
||||
},
|
||||
'brand.voice.precise.title': {
|
||||
en: 'Precise',
|
||||
'zh-CN': '精准'
|
||||
},
|
||||
'brand.voice.precise.body': {
|
||||
en: 'We use the real names for things. Nodes, samplers, seeds, checkpoints. We don’t talk around the product or reach for metaphor when the technical term is already good.',
|
||||
'zh-CN':
|
||||
'我们直呼其名:nodes、samplers、seeds、checkpoints。当技术术语已经足够贴切时,我们不绕弯子,也不借用比喻。'
|
||||
},
|
||||
'brand.voice.human.title': {
|
||||
en: 'Human-first',
|
||||
'zh-CN': '以人为先'
|
||||
},
|
||||
'brand.voice.human.body': {
|
||||
en: 'The human creates. Comfy makes every step visible. We never write as though the AI is doing the work.',
|
||||
'zh-CN':
|
||||
'创作的是人。Comfy 让每一步都清晰可见。我们绝不把功劳写成是 AI 完成的。'
|
||||
},
|
||||
'brand.voice.antihype.title': {
|
||||
en: 'Anti-hype',
|
||||
'zh-CN': '拒绝浮夸'
|
||||
},
|
||||
'brand.voice.antihype.body': {
|
||||
en: 'We don’t write “stunning,” “revolutionary,” or “effortless.” We don’t promise magic. Our tagline says exactly what we mean: Method, not magic.',
|
||||
'zh-CN':
|
||||
'我们不写“惊艳”“革命性”或“毫不费力”。我们不承诺魔法。我们的口号恰如其分:方法,而非魔法。'
|
||||
},
|
||||
'brand.voice.doLabel': {
|
||||
en: 'Do',
|
||||
'zh-CN': '推荐'
|
||||
},
|
||||
'brand.voice.dontLabel': {
|
||||
en: 'Don’t',
|
||||
'zh-CN': '避免'
|
||||
},
|
||||
'brand.voice.do.0': {
|
||||
en: 'Route your prompt through a ControlNet. Wire the output to the VAE decode.',
|
||||
'zh-CN': '让你的 prompt 经过 ControlNet,再将输出连接到 VAE decode。'
|
||||
},
|
||||
'brand.voice.do.1': {
|
||||
en: 'Comfy runs on your hardware. Nothing leaves your machine.',
|
||||
'zh-CN': 'Comfy 在你自己的硬件上运行。任何数据都不会离开你的机器。'
|
||||
},
|
||||
'brand.voice.dont.0': {
|
||||
en: 'Simply connect your AI blocks and watch the magic happen!',
|
||||
'zh-CN': '只需连接你的 AI 模块,见证奇迹的发生!'
|
||||
},
|
||||
'brand.voice.dont.1': {
|
||||
en: 'Oops! Something went wrong. Please try again later.',
|
||||
'zh-CN': '哎呀!出了点问题,请稍后再试。'
|
||||
},
|
||||
'brand.trademark.heading': {
|
||||
en: 'Trademark guidelines.',
|
||||
'zh-CN': '商标使用规范。'
|
||||
},
|
||||
'brand.trademark.body1': {
|
||||
en: 'Comfy and ComfyUI are trademarks of Comfy Org. You’re welcome to reference them in content that accurately describes your work with our platform. Tutorials, reviews, integrations, and affiliate content all qualify.',
|
||||
'zh-CN':
|
||||
'Comfy 和 ComfyUI 是 Comfy Org 的商标。欢迎在准确描述你与我们平台相关工作的内容中引用它们。教程、评测、集成以及联盟内容均可。'
|
||||
},
|
||||
'brand.trademark.body2': {
|
||||
en: 'A few rules: don’t modify the logo, don’t use the Comfy name in your own product or company name, and don’t present your content in a way that implies official endorsement or partnership beyond what’s been agreed.',
|
||||
'zh-CN':
|
||||
'几条规则:不要修改标志,不要在你自己的产品或公司名称中使用 Comfy 这一名称,也不要以暗示官方认可或合作关系(超出双方已达成的约定)的方式呈现你的内容。'
|
||||
},
|
||||
'brand.trademark.body3': {
|
||||
en: 'For permissions outside these guidelines,',
|
||||
'zh-CN': '如需本规范之外的授权,请'
|
||||
},
|
||||
'brand.trademark.contact': {
|
||||
en: 'Contact Us',
|
||||
'zh-CN': '联系我们'
|
||||
},
|
||||
'brand.questions.heading': {
|
||||
en: 'Questions?',
|
||||
'zh-CN': '有疑问?'
|
||||
},
|
||||
'brand.questions.body': {
|
||||
en: 'For press, partnerships, or anything outside these guidelines,',
|
||||
'zh-CN': '如涉及媒体、合作,或本规范未涵盖的任何事宜,请'
|
||||
},
|
||||
'brand.questions.contact': {
|
||||
en: 'Contact Us',
|
||||
'zh-CN': '联系我们'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
54
apps/website/src/pages/booking-confirmation.astro
Normal file
54
apps/website/src/pages/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: 'Learning Center',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: 'Workflow templates',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: 'Customer stories',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="You're booked - Comfy"
|
||||
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="You're booked." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>Check your email for the calendar invite and meeting link!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
Resources while you wait
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
28
apps/website/src/pages/brand.astro
Normal file
28
apps/website/src/pages/brand.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import BrandBackground from '../templates/brand/BrandBackground.vue'
|
||||
import BrandHeroSection from '../templates/brand/BrandHeroSection.vue'
|
||||
import BrandLogosSection from '../templates/brand/BrandLogosSection.vue'
|
||||
import BrandColorSection from '../templates/brand/BrandColorSection.vue'
|
||||
import BrandVoiceSection from '../templates/brand/BrandVoiceSection.vue'
|
||||
import BrandTrademarkSection from '../templates/brand/BrandTrademarkSection.vue'
|
||||
import BrandQuestionsSection from '../templates/brand/BrandQuestionsSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('brand.page.title', locale)}
|
||||
description={t('brand.page.description', locale)}
|
||||
>
|
||||
<div class="relative">
|
||||
<BrandBackground client:idle />
|
||||
<BrandHeroSection />
|
||||
<BrandLogosSection />
|
||||
<BrandColorSection client:visible />
|
||||
<BrandVoiceSection />
|
||||
<BrandTrademarkSection />
|
||||
<BrandQuestionsSection />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
@@ -30,7 +31,7 @@ const { siteUrl, locale } = pageContext(
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
|
||||
60
apps/website/src/pages/individual-submission.astro
Normal file
60
apps/website/src/pages/individual-submission.astro
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Thanks for reaching out - Comfy"
|
||||
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="Thanks for reaching out." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
Based on what you shared, one of our self-serve plans is probably a
|
||||
better fit.
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>,
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>, and
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
> for individual creators, plus our new
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> plan for multiple users under shared billing.
|
||||
</p>
|
||||
|
||||
<PlansPricingCta
|
||||
href={routes.cloudPricing}
|
||||
label="See plans and pricing →"
|
||||
/>
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
Need help with something else? Email: <a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: '学习中心',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: '工作流模板',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: '客户案例',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: '文档',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="预约成功 - Comfy"
|
||||
description="您的会议已预约成功。请查收邮件中的日历邀请和会议链接。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="预约成功。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>请查收邮件中的日历邀请和会议链接!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
等待期间的资源
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
26
apps/website/src/pages/zh-CN/brand.astro
Normal file
26
apps/website/src/pages/zh-CN/brand.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import BrandBackground from '../../templates/brand/BrandBackground.vue'
|
||||
import BrandHeroSection from '../../templates/brand/BrandHeroSection.vue'
|
||||
import BrandLogosSection from '../../templates/brand/BrandLogosSection.vue'
|
||||
import BrandColorSection from '../../templates/brand/BrandColorSection.vue'
|
||||
import BrandVoiceSection from '../../templates/brand/BrandVoiceSection.vue'
|
||||
import BrandTrademarkSection from '../../templates/brand/BrandTrademarkSection.vue'
|
||||
import BrandQuestionsSection from '../../templates/brand/BrandQuestionsSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('brand.page.title', 'zh-CN')}
|
||||
description={t('brand.page.description', 'zh-CN')}
|
||||
>
|
||||
<div class="relative">
|
||||
<BrandBackground client:idle />
|
||||
<BrandHeroSection locale="zh-CN" />
|
||||
<BrandLogosSection locale="zh-CN" />
|
||||
<BrandColorSection client:visible locale="zh-CN" />
|
||||
<BrandVoiceSection locale="zh-CN" />
|
||||
<BrandTrademarkSection locale="zh-CN" />
|
||||
<BrandQuestionsSection locale="zh-CN" />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
@@ -33,7 +34,7 @@ const { siteUrl, locale } = pageContext(
|
||||
},
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
|
||||
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="感谢您的联系 - Comfy"
|
||||
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="感谢您的联系。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>、
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>
|
||||
和
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
>,以及我们全新的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> 套餐,可让多位用户共享账单。
|
||||
</p>
|
||||
|
||||
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
需要其他方面的帮助?邮箱:<a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
112
apps/website/src/templates/brand/BrandBackground.vue
Normal file
112
apps/website/src/templates/brand/BrandBackground.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useEventListener,
|
||||
useIntersectionObserver,
|
||||
useRafFn
|
||||
} from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
|
||||
interface Node {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
}
|
||||
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
let dpr =
|
||||
typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1
|
||||
|
||||
const nodes: Node[] = Array.from({ length: 14 }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
vx: (Math.random() - 0.5) * 0.0005,
|
||||
vy: (Math.random() - 0.5) * 0.0005
|
||||
}))
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
|
||||
function draw() {
|
||||
const el = canvasEl.value
|
||||
if (!el || !ctx) return
|
||||
const w = el.width
|
||||
const h = el.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
for (const n of nodes) {
|
||||
n.x += n.vx
|
||||
n.y += n.vy
|
||||
if (n.x < 0 || n.x > 1) n.vx *= -1
|
||||
if (n.y < 0 || n.y > 1) n.vy *= -1
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(242, 255, 89, 0.18)'
|
||||
ctx.lineWidth = dpr
|
||||
const max = Math.min(w, h) * 0.22
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i]
|
||||
const b = nodes[j]
|
||||
const dx = (a.x - b.x) * w
|
||||
const dy = (a.y - b.y) * h
|
||||
const d = Math.hypot(dx, dy)
|
||||
if (d < max) {
|
||||
ctx.globalAlpha = 1 - d / max
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x * w, a.y * h)
|
||||
const mx = ((a.x + b.x) / 2) * w
|
||||
ctx.bezierCurveTo(mx, a.y * h, mx, b.y * h, b.x * w, b.y * h)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.fillStyle = 'rgba(242, 255, 89, 0.9)'
|
||||
for (const n of nodes) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(n.x * w, n.y * h, 2.5 * dpr, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// Resizing clears the canvas bitmap, so repaint immediately afterwards to keep
|
||||
// the field visible even when the RAF loop is paused (off screen or
|
||||
// reduced-motion). Also refresh dpr in case the window moved to another display.
|
||||
function resize() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
el.width = el.offsetWidth * dpr
|
||||
el.height = el.offsetHeight * dpr
|
||||
draw()
|
||||
}
|
||||
|
||||
const { pause, resume } = useRafFn(draw, { immediate: false })
|
||||
|
||||
// Only animate while the field is on screen, and honour reduced-motion by
|
||||
// painting a single static frame instead of looping.
|
||||
useIntersectionObserver(canvasEl, ([entry]) => {
|
||||
if (prefersReducedMotion()) return
|
||||
if (entry?.isIntersecting) resume()
|
||||
else pause()
|
||||
})
|
||||
|
||||
useEventListener('resize', resize)
|
||||
|
||||
onMounted(() => {
|
||||
ctx = canvasEl.value?.getContext('2d') ?? null
|
||||
resize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-screen w-full"
|
||||
/>
|
||||
</template>
|
||||
93
apps/website/src/templates/brand/BrandColorSection.vue
Normal file
93
apps/website/src/templates/brand/BrandColorSection.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import { brandColors } from '../../data/brandColors'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const specRows = ['hex', 'rgb', 'hsl', 'cmyk'] as const
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
|
||||
const copiedHex = ref<string | null>(null)
|
||||
const copiedValue = ref('')
|
||||
|
||||
function copyValue(hex: string, value: string) {
|
||||
copiedHex.value = hex
|
||||
copiedValue.value = value
|
||||
void copy(value)
|
||||
}
|
||||
|
||||
function isCardCopied(hex: string) {
|
||||
return copied.value && copiedHex.value === hex
|
||||
}
|
||||
|
||||
const liveMessage = computed(() =>
|
||||
copied.value ? `${t('brand.colors.copied', locale)} ${copiedValue.value}` : ''
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.colors.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
|
||||
{{ t('brand.colors.subheading', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
|
||||
|
||||
<ul class="mt-10 grid grid-cols-2 gap-4 lg:grid-cols-5">
|
||||
<li
|
||||
v-for="color in brandColors"
|
||||
:key="color.hex"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-[123px] cursor-pointer flex-col rounded-[30px] p-6',
|
||||
color.swatchClass,
|
||||
color.textClass,
|
||||
color.wide && 'lg:col-span-2',
|
||||
color.border && 'border-primary-warm-gray border-[0.783px]'
|
||||
)
|
||||
"
|
||||
@click="copyValue(color.hex, color.hex)"
|
||||
>
|
||||
<div
|
||||
v-if="isCardCopied(color.hex)"
|
||||
class="flex flex-1 items-center justify-center text-center text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ t('brand.colors.copied', locale) }} {{ copiedValue }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs font-semibold">{{ color.name }}</span>
|
||||
<dl
|
||||
class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 text-xs leading-[1.4]"
|
||||
>
|
||||
<template v-for="row in specRows" :key="row">
|
||||
<dt class="uppercase opacity-50">{{ row }}</dt>
|
||||
<dd>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="`${t('brand.colors.copy', locale)} ${row} ${color[row]}`"
|
||||
class="cursor-pointer text-left hover:underline"
|
||||
@click.stop="copyValue(color.hex, color[row])"
|
||||
>
|
||||
{{ color[row] }}
|
||||
</button>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
55
apps/website/src/templates/brand/BrandHeroSection.vue
Normal file
55
apps/website/src/templates/brand/BrandHeroSection.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import Button from '../../components/ui/button/Button.vue'
|
||||
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../../data/brandAssets'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto px-6 pt-4 pb-10 text-center lg:px-20 lg:pb-12"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-extrabold tracking-[0.7px] uppercase"
|
||||
>
|
||||
{{ t('brand.hero.label', locale) }}
|
||||
</p>
|
||||
<h1
|
||||
class="lg:text-6.5xl mx-auto mt-6 max-w-4xl text-4xl leading-[1.3] font-light tracking-[-0.03em] text-primary-comfy-canvas md:text-5xl"
|
||||
>
|
||||
{{ t('brand.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
class="mx-auto mt-6 max-w-2xl text-[17px] leading-[1.6] font-light text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ t('brand.hero.subheading', locale) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row"
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
:href="BRAND_GUIDELINES_PDF"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
|
||||
>
|
||||
{{ t('brand.hero.viewGuidelines', locale) }}
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
:href="BRAND_ASSETS_ZIP"
|
||||
download
|
||||
variant="outline"
|
||||
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
|
||||
>
|
||||
{{ t('brand.hero.downloadLogos', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
58
apps/website/src/templates/brand/BrandLogosSection.vue
Normal file
58
apps/website/src/templates/brand/BrandLogosSection.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const assets = affiliateBrandAssets.map((asset) =>
|
||||
asset.id === 'icon' ? { ...asset, preview: '/icons/comfyicon.svg' } : asset
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="logos" class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.logos.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
|
||||
{{ t('brand.logos.subheading', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<ul class="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<li
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
class="flex min-h-60 flex-col rounded-[30px] border-[1.5px] border-white/8 lg:min-h-[285px]"
|
||||
>
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<img
|
||||
:src="asset.preview"
|
||||
:alt="asset.title[locale]"
|
||||
:class="
|
||||
cn(
|
||||
'object-contain',
|
||||
asset.id === 'icon'
|
||||
? 'size-24 rounded-[23%] border border-white/10'
|
||||
: 'max-h-24 max-w-[75%]'
|
||||
)
|
||||
"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="pb-8 text-center text-[21px] font-medium tracking-[1.05px] text-primary-comfy-canvas"
|
||||
>
|
||||
{{ asset.title[locale] }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
33
apps/website/src/templates/brand/BrandQuestionsSection.vue
Normal file
33
apps/website/src/templates/brand/BrandQuestionsSection.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import Button from '../../components/ui/button/Button.vue'
|
||||
import { externalLinks } from '../../config/routes.ts'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto px-6 pt-10 pb-24 lg:px-20 lg:pt-12 lg:pb-32"
|
||||
>
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.questions.heading', locale) }}
|
||||
</SectionHeader>
|
||||
|
||||
<p class="text-primary-warm-gray mt-6 max-w-2xl text-sm leading-[1.6]">
|
||||
{{ t('brand.questions.body', locale) }}
|
||||
<Button
|
||||
as="a"
|
||||
variant="inline"
|
||||
:href="externalLinks.support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('brand.questions.contact', locale) }}
|
||||
</Button>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
37
apps/website/src/templates/brand/BrandTrademarkSection.vue
Normal file
37
apps/website/src/templates/brand/BrandTrademarkSection.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import Button from '../../components/ui/button/Button.vue'
|
||||
import { externalLinks } from '../../config/routes.ts'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.trademark.heading', locale) }}
|
||||
</SectionHeader>
|
||||
|
||||
<div
|
||||
class="text-primary-warm-gray mt-6 flex max-w-4xl flex-col gap-4 text-sm leading-[1.6]"
|
||||
>
|
||||
<p>{{ t('brand.trademark.body1', locale) }}</p>
|
||||
<p>{{ t('brand.trademark.body2', locale) }}</p>
|
||||
<p>
|
||||
{{ t('brand.trademark.body3', locale) }}
|
||||
<Button
|
||||
as="a"
|
||||
variant="inline"
|
||||
:href="externalLinks.support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('brand.trademark.contact', locale) }}
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
100
apps/website/src/templates/brand/BrandVoiceSection.vue
Normal file
100
apps/website/src/templates/brand/BrandVoiceSection.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const principles = [
|
||||
{
|
||||
title: t('brand.voice.direct.title', locale),
|
||||
body: t('brand.voice.direct.body', locale)
|
||||
},
|
||||
{
|
||||
title: t('brand.voice.precise.title', locale),
|
||||
body: t('brand.voice.precise.body', locale)
|
||||
},
|
||||
{
|
||||
title: t('brand.voice.human.title', locale),
|
||||
body: t('brand.voice.human.body', locale)
|
||||
},
|
||||
{
|
||||
title: t('brand.voice.antihype.title', locale),
|
||||
body: t('brand.voice.antihype.body', locale)
|
||||
}
|
||||
]
|
||||
|
||||
const doExamples = [
|
||||
t('brand.voice.do.0', locale),
|
||||
t('brand.voice.do.1', locale)
|
||||
]
|
||||
|
||||
const dontExamples = [
|
||||
t('brand.voice.dont.0', locale),
|
||||
t('brand.voice.dont.1', locale)
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.voice.heading', locale) }}
|
||||
</SectionHeader>
|
||||
|
||||
<dl class="mt-10 flex max-w-4xl flex-col gap-3.5 text-sm leading-[1.6]">
|
||||
<div v-for="principle in principles" :key="principle.title">
|
||||
<dt class="text-primary-comfy-yellow">{{ principle.title }}</dt>
|
||||
<dd class="text-primary-warm-gray">{{ principle.body }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-12 grid gap-4 md:grid-cols-2">
|
||||
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow size-2.5 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-bold tracking-wider text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ t('brand.voice.doLabel', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="example in doExamples"
|
||||
:key="example"
|
||||
class="bg-transparency-ink-t80 rounded-2xl p-5"
|
||||
>
|
||||
<p class="text-base/[1.45] text-primary-comfy-canvas">
|
||||
{{ example }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="bg-primary-warm-gray size-2.5 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="text-primary-warm-gray text-sm font-bold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('brand.voice.dontLabel', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="example in dontExamples"
|
||||
:key="example"
|
||||
class="bg-transparency-ink-t80 rounded-2xl p-5"
|
||||
>
|
||||
<p class="text-primary-warm-gray text-base/[1.45] line-through">
|
||||
{{ example }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,35 +7,40 @@ import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const PROMPT = t('mcp.hero.demoPrompt', locale)
|
||||
const generateLabel = t('mcp.hero.demoGenerate', locale)
|
||||
|
||||
// Each cycle types the prompt that produces the card about to slide in.
|
||||
const cards = [
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptMoodboard',
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'moodboard_v1.png · 6-up',
|
||||
tag: 'Gmail',
|
||||
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptConcepts',
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'concepts_01–03.png',
|
||||
tag: 'Notion',
|
||||
thumb: '/images/mcp/mcp-thumb-concepts.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptKeyart',
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'hero_keyart.png',
|
||||
tag: 'Figma',
|
||||
thumb: '/images/mcp/mcp-thumb-keyart.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptPbr',
|
||||
actionKey: 'mcp.hero.demoActionGenerate3d',
|
||||
file: 'asphalt_pbr/ · 5 maps',
|
||||
tag: 'Blender',
|
||||
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptUpscale',
|
||||
actionKey: 'mcp.hero.demoActionUpscale',
|
||||
file: 'kaiju_neon_4k.png · 4096',
|
||||
tag: null,
|
||||
@@ -65,15 +70,15 @@ function schedule(fn: () => void, ms: number) {
|
||||
}, ms)
|
||||
}
|
||||
|
||||
function typePrompt(onDone: () => void) {
|
||||
function typePrompt(prompt: string, onDone: () => void) {
|
||||
displayedPrompt.value = ''
|
||||
promptDone.value = false
|
||||
let i = 0
|
||||
|
||||
function step() {
|
||||
i++
|
||||
displayedPrompt.value = PROMPT.slice(0, i)
|
||||
if (i < PROMPT.length) {
|
||||
displayedPrompt.value = prompt.slice(0, i)
|
||||
if (i < prompt.length) {
|
||||
schedule(step, 35)
|
||||
} else {
|
||||
promptDone.value = true
|
||||
@@ -94,8 +99,8 @@ function revealNextCard() {
|
||||
return
|
||||
}
|
||||
|
||||
// Type the prompt, then slide in the next card
|
||||
typePrompt(() => {
|
||||
// Type the next card's prompt, then slide that card in
|
||||
typePrompt(t(cards[visibleCount.value].promptKey, locale), () => {
|
||||
visibleCount.value++
|
||||
schedule(revealNextCard, 400)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
|
||||
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const
|
||||
|
||||
const faqs = faqNumbers.map((n) => ({
|
||||
id: String(n),
|
||||
|
||||
@@ -11,9 +11,10 @@ const ctas = mcpCtas(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 5rem/6.75rem = HeaderMain's rendered height (py-5 / lg:py-8) so the hero fills the viewport below the sticky nav -->
|
||||
<HeroSplit01
|
||||
:locale="locale"
|
||||
class="min-h-screen"
|
||||
class="min-h-[calc(100svh-5rem)] lg:min-h-[calc(100svh-6.75rem)]"
|
||||
badge-text="MCP"
|
||||
:title="t('mcp.hero.heading', locale)"
|
||||
:subtitle="t('mcp.hero.subtitle', locale)"
|
||||
|
||||
@@ -23,7 +23,7 @@ const steps: FeatureStep[] = stepNumbers.map((n) => ({
|
||||
<FeatureGrid02
|
||||
:heading="t('mcp.howItWorks.heading', locale)"
|
||||
:steps="steps"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:primary-cta="ctas.installMcp"
|
||||
:secondary-cta="ctas.docs"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,69 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
|
||||
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import SectionLabel from '../../components/common/SectionLabel.vue'
|
||||
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const cards: FeatureCard[] = [
|
||||
const agentCommand = t('mcp.setup.option1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
|
||||
interface McpClient {
|
||||
id: string
|
||||
name: string
|
||||
step: string
|
||||
command?: string
|
||||
link?: { label: string; href: string }
|
||||
}
|
||||
|
||||
const clients: McpClient[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
label: t('mcp.setup.step1.label', locale),
|
||||
title: t('mcp.setup.step1.title', locale),
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: t('mcp.setup.step1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code',
|
||||
step: t('mcp.setup.clients.claudeCode.step', locale),
|
||||
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
name: 'Claude Desktop',
|
||||
step: t('mcp.setup.clients.claudeDesktop.step', locale)
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
name: 'Cursor',
|
||||
step: t('mcp.setup.clients.cursor.step', locale),
|
||||
link: {
|
||||
label: t('mcp.setup.clients.cursor.linkLabel', locale),
|
||||
href: externalLinks.apiKeys
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
label: t('mcp.setup.step2.label', locale),
|
||||
title: t('mcp.setup.step2.title', locale),
|
||||
description: t('mcp.setup.step2.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step2.cta', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
step: t('mcp.setup.clients.codex.step', locale),
|
||||
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
label: t('mcp.setup.step3.label', locale),
|
||||
title: t('mcp.setup.step3.title', locale),
|
||||
description: t('mcp.setup.step3.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step3.cta', locale),
|
||||
href: externalLinks.mcpSkills,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
id: 'other',
|
||||
name: t('mcp.setup.clients.other.name', locale),
|
||||
step: t('mcp.setup.clients.other.step', locale),
|
||||
link: {
|
||||
label: t('mcp.setup.clients.other.linkLabel', locale),
|
||||
href: externalLinks.docsMcp
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const copyLabel = t('ui.copy', locale)
|
||||
const copiedLabel = t('ui.copied', locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureGrid01
|
||||
<section
|
||||
id="setup"
|
||||
class="scroll-mt-24 lg:scroll-mt-36"
|
||||
:eyebrow="t('mcp.setup.label', locale)"
|
||||
:heading="t('mcp.setup.heading', locale)"
|
||||
:subtitle="t('mcp.setup.subtitle', locale)"
|
||||
:columns="3"
|
||||
:cards="cards"
|
||||
:copy-label="t('ui.copy', locale)"
|
||||
:copied-label="t('ui.copied', locale)"
|
||||
/>
|
||||
class="max-w-9xl mx-auto scroll-mt-24 px-6 py-16 lg:scroll-mt-36 lg:py-24"
|
||||
>
|
||||
<SectionHeader
|
||||
max-width="xl"
|
||||
:label="t('mcp.setup.label', locale)"
|
||||
align="start"
|
||||
>
|
||||
{{ t('mcp.setup.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ t('mcp.setup.subtitle', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div class="mt-16 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
|
||||
<h3
|
||||
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
>
|
||||
{{ t('mcp.setup.option1.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.option1.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="agentCommand"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-auto pt-6 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.skillsNote', locale)
|
||||
}}<a
|
||||
:href="externalLinks.mcpSkills"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ t('mcp.setup.skillsLink', locale) }}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<SectionLabel>{{ t('mcp.setup.option2.label', locale) }}</SectionLabel>
|
||||
<h3
|
||||
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
>
|
||||
{{ t('mcp.setup.option2.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.option2.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="externalLinks.mcpEndpoint"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsRoot default-value="claude-code" class="mt-6">
|
||||
<TabsList
|
||||
:aria-label="t('mcp.setup.option2.tabsLabel', locale)"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<TabsTrigger
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="bg-transparency-white-t4 focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow cursor-pointer rounded-full px-4 py-2 text-xs font-bold tracking-wider text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink"
|
||||
>
|
||||
{{ client.name }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="mt-4 flex min-h-24 flex-col gap-3"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ client.step
|
||||
}}<a
|
||||
v-if="client.link"
|
||||
:href="client.link.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ client.link.label }}</a
|
||||
>
|
||||
</p>
|
||||
<CopyableField
|
||||
v-if="client.command"
|
||||
:value="client.command"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -7,16 +7,21 @@ import { t } from '../../i18n/translations'
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
type ToolMedia =
|
||||
| { type: 'image'; src: string }
|
||||
| { type: 'image'; src: string; fit?: 'cover' | 'contain' }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
}
|
||||
|
||||
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
|
||||
const tools: {
|
||||
n: 1 | 2 | 3 | 4 | 5 | 6
|
||||
media: ToolMedia
|
||||
altKey?: TranslationKey
|
||||
}[] = [
|
||||
{
|
||||
n: 1,
|
||||
media: {
|
||||
@@ -40,9 +45,38 @@ const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
|
||||
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
hideControls: true
|
||||
hideControls: true,
|
||||
fit: 'contain'
|
||||
},
|
||||
altKey: 'mcp.tools.3.alt'
|
||||
},
|
||||
{
|
||||
n: 4,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/direct-any-model.png'
|
||||
},
|
||||
altKey: 'mcp.tools.4.alt'
|
||||
},
|
||||
{
|
||||
n: 5,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/generate-in-batches.png'
|
||||
},
|
||||
altKey: 'mcp.tools.5.alt'
|
||||
},
|
||||
{
|
||||
n: 6,
|
||||
media: {
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/homepage/showcase/ui-overview.webm',
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
hideControls: true,
|
||||
fit: 'contain'
|
||||
},
|
||||
altKey: 'mcp.tools.6.alt'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
@@ -9,14 +9,13 @@ export interface McpCta {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
|
||||
* steps, or run a workflow in the cloud. The hero leads with install + docs;
|
||||
* the "how it works" section pairs run-a-workflow with docs.
|
||||
* Calls-to-action for the MCP page: view the docs or jump to the on-page
|
||||
* setup options. Both the hero and the "how it works" section pair install
|
||||
* with docs.
|
||||
*/
|
||||
export function mcpCtas(locale: Locale): {
|
||||
docs: McpCta
|
||||
installMcp: McpCta
|
||||
runWorkflow: McpCta
|
||||
} {
|
||||
return {
|
||||
docs: {
|
||||
@@ -27,10 +26,6 @@ export function mcpCtas(locale: Locale): {
|
||||
installMcp: {
|
||||
label: t('mcp.hero.installMcp', locale),
|
||||
href: '#setup'
|
||||
},
|
||||
runWorkflow: {
|
||||
label: t('mcp.hero.runWorkflow', locale),
|
||||
href: getRoutes(locale).cloud
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,31 @@ describe('comfyUiSourceCodeNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyUI entity links', () => {
|
||||
it('names the home page as the canonical page for the application', () => {
|
||||
const node = comfyUiApplicationNode(siteUrl)
|
||||
expect(node.mainEntityOfPage).toBe(`${siteUrl}/`)
|
||||
})
|
||||
|
||||
it('links the application back to the source code emitted alongside it', () => {
|
||||
const app = comfyUiApplicationNode(siteUrl)
|
||||
const source = comfyUiSourceCodeNode(siteUrl)
|
||||
expect(app.isBasedOn).toEqual({ '@id': source['@id'] })
|
||||
})
|
||||
|
||||
it('claims no canonical page or source code 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.mainEntityOfPage).toBeUndefined()
|
||||
expect(node.isBasedOn).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPageGraph', () => {
|
||||
const url = 'https://comfy.org/cloud/pricing/'
|
||||
const graph = buildPageGraph(
|
||||
|
||||
@@ -194,6 +194,8 @@ export interface SoftwareAppInput {
|
||||
authorName?: string
|
||||
isFree?: boolean
|
||||
sameAs?: string[]
|
||||
mainEntityOfPage?: string
|
||||
isBasedOnId?: string
|
||||
}
|
||||
|
||||
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
@@ -219,6 +221,8 @@ export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
author,
|
||||
publisher: input.firstParty ? orgRef : undefined,
|
||||
sameAs: input.sameAs,
|
||||
mainEntityOfPage: input.mainEntityOfPage,
|
||||
isBasedOn: input.isBasedOnId ? { '@id': input.isBasedOnId } : undefined,
|
||||
offers: input.isFree
|
||||
? {
|
||||
'@type': 'Offer',
|
||||
@@ -257,6 +261,10 @@ export function comfyUiSoftwareId(siteUrl: string): string {
|
||||
return `${siteUrl}/#software`
|
||||
}
|
||||
|
||||
function comfyUiSourceCodeId(siteUrl: string): string {
|
||||
return `${siteUrl}/#sourcecode`
|
||||
}
|
||||
|
||||
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
return softwareApplicationNode({
|
||||
siteUrl,
|
||||
@@ -267,14 +275,16 @@ export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Windows, macOS, Linux',
|
||||
isFree: true,
|
||||
sameAs: comfyUiSameAs
|
||||
sameAs: comfyUiSameAs,
|
||||
mainEntityOfPage: `${siteUrl}/`,
|
||||
isBasedOnId: comfyUiSourceCodeId(siteUrl)
|
||||
})
|
||||
}
|
||||
|
||||
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
|
||||
return softwareSourceCodeNode({
|
||||
siteUrl,
|
||||
id: `${siteUrl}/#sourcecode`,
|
||||
id: comfyUiSourceCodeId(siteUrl),
|
||||
name: 'ComfyUI',
|
||||
codeRepository: externalLinks.github,
|
||||
programmingLanguage: 'Python',
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/login",
|
||||
"destination": "https://cloud.comfy.org/login",
|
||||
"permanent": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,45 @@ import {
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Vue Upload Widgets', { tag: '@vue-nodes' }, () => {
|
||||
test.describe('media selection', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
})
|
||||
|
||||
test('keeps a selected image loaded when it is selected again', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
expect(loadImageNodes, 'Workflow has one Load Image node').toHaveLength(1)
|
||||
const [loadImageNode] = loadImageNodes
|
||||
|
||||
const imageWidget = await loadImageNode.getWidgetByName('image')
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const imageLoadError = node.getByTestId(TestIds.errors.imageLoadError)
|
||||
const selectedImageButton = node.getByRole('button', {
|
||||
name: 'example.png',
|
||||
exact: true
|
||||
})
|
||||
await expect(selectedImageButton).toBeVisible()
|
||||
await expect(imageLoadError).toBeHidden()
|
||||
|
||||
await selectedImageButton.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByText('example.png', { exact: true }).click()
|
||||
await expect(menu).toBeHidden()
|
||||
|
||||
await expect(selectedImageButton).toBeFocused()
|
||||
await expect(selectedImageButton).toBeVisible()
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
|
||||
await expect(imageLoadError).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ export type ComfyDesktop2TelemetryProperties = Record<
|
||||
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
|
||||
>
|
||||
|
||||
export type ComfyDesktop2FirebaseAuthState =
|
||||
| { status: 'pending' }
|
||||
| { status: 'signed_out' }
|
||||
| { status: 'signed_in'; userId: string }
|
||||
|
||||
export interface ComfyDesktop2TerminalBridge {
|
||||
subscribe(installationId?: string): Promise<TerminalRestore>
|
||||
unsubscribe(installationId?: string): Promise<void>
|
||||
@@ -60,6 +65,7 @@ export interface ComfyDesktop2LogsBridge {
|
||||
|
||||
export interface ComfyDesktop2TelemetryBridge {
|
||||
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
|
||||
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2Bridge {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-desktop-bridge-types",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
|
||||
"homepage": "https://comfy.org",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
escapeVueI18nMessageSyntax,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -478,49 +477,4 @@ describe('formatUtil', () => {
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeVueI18nMessageSyntax', () => {
|
||||
it('escapes a literal @ that would break linked-message compilation', () => {
|
||||
expect(
|
||||
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
|
||||
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
|
||||
})
|
||||
|
||||
it('escapes @ in an email address', () => {
|
||||
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
|
||||
"support{'@'}comfy.org"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes interpolation braces', () => {
|
||||
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
|
||||
"size {'{'}w{'}'}x{'{'}h{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the plural pipe separator', () => {
|
||||
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
|
||||
"foreground {'|'} background"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the modulo percent so it cannot re-form %{', () => {
|
||||
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
|
||||
"50{'%'}{'{'}done{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes every occurrence in a single pass', () => {
|
||||
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
|
||||
"{'@'}a {'@'}b {'@'}c"
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves strings without syntax characters unchanged', () => {
|
||||
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
|
||||
'no special chars here'
|
||||
)
|
||||
expect(escapeVueI18nMessageSyntax('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,40 +178,6 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
|
||||
@@ -3,11 +3,9 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import type { WidgetLabels } from './nodeDefLocaleSerializer'
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
@@ -17,10 +15,6 @@ interface WidgetInfo {
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface WidgetLabels {
|
||||
[key: string]: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
@@ -47,26 +41,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
const inputDataTypes = Object.values(nodeDef.inputs).map(
|
||||
(inputSpec) => inputSpec.type
|
||||
)
|
||||
const outputDataTypes = nodeDef.outputs.map(
|
||||
(outputSpec) => outputSpec.type
|
||||
)
|
||||
const allDataTypes = [...inputDataTypes, ...outputDataTypes].flatMap(
|
||||
(type: string) => type.split(',')
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
async function extractWidgetLabels() {
|
||||
const nodeLabels: WidgetLabels = {}
|
||||
|
||||
@@ -95,14 +69,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
[nodeDef.name, nodeDef.display_name, inputNames]
|
||||
)
|
||||
|
||||
// Format runtime widgets
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
.map(([key, name]) => [key, { name }])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -121,97 +91,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
|
||||
const nodeDefLabels = await extractWidgetLabels()
|
||||
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(input.name),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function extractOutputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
i.toString(),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
const allNodeDefsLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...extractInputs(nodeDef),
|
||||
...(nodeDefLabels[nodeDef.name] ?? {})
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const allNodeCategoriesLocale = Object.fromEntries(
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(nodeDefs, nodeDefLabels)
|
||||
|
||||
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
|
||||
fs.writeFileSync(
|
||||
@@ -219,13 +100,13 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
JSON.stringify(
|
||||
{
|
||||
...locale,
|
||||
dataTypes: allDataTypesLocale,
|
||||
nodeCategories: allNodeCategoriesLocale
|
||||
dataTypes,
|
||||
nodeCategories
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(allNodeDefsLocale, null, 2))
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(nodeDefinitions, null, 2))
|
||||
})
|
||||
|
||||
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
function render(message: string): string {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
describe('serializeNodeDefLocales', () => {
|
||||
it('escapes compiled fields and preserves raw tooltips', () => {
|
||||
const syntax = '@ $ {value} | 50%{done}'
|
||||
const inputName = `Input ${syntax}`
|
||||
const outputName = `Output ${syntax}`
|
||||
const dataType = `TYPE ${syntax}`
|
||||
const category = `Category ${syntax}`
|
||||
const nodeDef = {
|
||||
name: 'Test.Node',
|
||||
display_name: `Display ${syntax}`,
|
||||
description: `Description ${syntax}`,
|
||||
category,
|
||||
inputs: {
|
||||
input: {
|
||||
name: inputName,
|
||||
type: dataType,
|
||||
tooltip: `Input tooltip ${syntax}`
|
||||
}
|
||||
},
|
||||
outputs: [
|
||||
{
|
||||
name: outputName,
|
||||
type: 'OTHER',
|
||||
tooltip: `Output tooltip ${syntax}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales([nodeDef], {
|
||||
'Test.Node': {
|
||||
'Runtime.Widget': { name: `Widget ${syntax}` }
|
||||
}
|
||||
})
|
||||
const serializedNode = nodeDefinitions.Test_Node
|
||||
const serializedInput =
|
||||
serializedNode.inputs['Input @ $ {value} | 50%{done}']
|
||||
const serializedOutput = serializedNode.outputs['0']
|
||||
|
||||
expect(render(serializedNode.display_name)).toBe(nodeDef.display_name)
|
||||
expect(render(serializedNode.description)).toBe(nodeDef.description)
|
||||
expect(render(serializedInput.name)).toBe(inputName)
|
||||
expect(render(serializedOutput.name)).toBe(outputName)
|
||||
expect(render(serializedNode.inputs.Runtime_Widget.name)).toBe(
|
||||
`Widget ${syntax}`
|
||||
)
|
||||
expect(render(dataTypes[dataType])).toBe(dataType)
|
||||
expect(render(nodeCategories[category])).toBe(category)
|
||||
expect(serializedInput.tooltip).toBe(nodeDef.inputs.input.tooltip)
|
||||
expect(serializedOutput.tooltip).toBe(nodeDef.outputs[0].tooltip)
|
||||
})
|
||||
|
||||
it('preserves locale shapes and ordering', () => {
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(
|
||||
[
|
||||
{
|
||||
name: 'Z.Node',
|
||||
description: '',
|
||||
category: 'group/sub.group',
|
||||
inputs: {
|
||||
omitted: { type: 'Z.TYPE' },
|
||||
tooltipOnly: { type: 'A_TYPE', tooltip: 'raw @ tooltip' }
|
||||
},
|
||||
outputs: [
|
||||
{ name: 'A_TYPE', type: 'A_TYPE' },
|
||||
{ name: 'Custom.Output', type: 'Z.TYPE' },
|
||||
{ tooltip: 'raw output @ tooltip', type: 'Z.TYPE' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'A.Node',
|
||||
category: 'group',
|
||||
inputs: {},
|
||||
outputs: []
|
||||
}
|
||||
],
|
||||
{
|
||||
'Z.Node': {
|
||||
'Runtime.Widget': { name: 'Runtime.Label' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(dataTypes).toEqual({
|
||||
A_TYPE: 'A_TYPE',
|
||||
Z_TYPE: 'Z.TYPE'
|
||||
})
|
||||
expect(nodeCategories).toEqual({
|
||||
group: 'group',
|
||||
sub_group: 'sub.group'
|
||||
})
|
||||
expect(nodeDefinitions).toEqual({
|
||||
A_Node: {
|
||||
display_name: 'A.Node',
|
||||
description: undefined,
|
||||
inputs: undefined,
|
||||
outputs: undefined
|
||||
},
|
||||
Z_Node: {
|
||||
display_name: 'Z.Node',
|
||||
description: undefined,
|
||||
inputs: {
|
||||
'': { name: undefined, tooltip: 'raw @ tooltip' },
|
||||
Runtime_Widget: { name: 'Runtime.Label' }
|
||||
},
|
||||
outputs: {
|
||||
1: { name: 'Custom.Output', tooltip: undefined },
|
||||
2: { name: undefined, tooltip: 'raw output @ tooltip' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(Object.keys(dataTypes)).toEqual(['A_TYPE', 'Z_TYPE'])
|
||||
expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node'])
|
||||
})
|
||||
})
|
||||
127
scripts/nodeDefLocaleSerializer.ts
Normal file
127
scripts/nodeDefLocaleSerializer.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
interface LocalizableInput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableOutput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableNodeDef {
|
||||
category: string
|
||||
inputs: Record<string, LocalizableInput>
|
||||
name: string
|
||||
outputs: LocalizableOutput[]
|
||||
description?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export type WidgetLabels = Record<
|
||||
string,
|
||||
Record<string, { name: string | undefined }>
|
||||
>
|
||||
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g
|
||||
|
||||
function escapeMessage(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
export function serializeNodeDefLocales(
|
||||
nodeDefs: readonly LocalizableNodeDef[],
|
||||
widgetLabels: WidgetLabels = {}
|
||||
) {
|
||||
const dataTypes = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => [
|
||||
...Object.values(nodeDef.inputs).map(({ type }) => type),
|
||||
...nodeDef.outputs.map(({ type }) => type)
|
||||
])
|
||||
.flatMap((type) => type.split(','))
|
||||
.map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)])
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
function serializeInputs(nodeDef: LocalizableNodeDef) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap(({ name, tooltip }) => {
|
||||
if (name === undefined && tooltip === undefined) return []
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(name ?? ''),
|
||||
{
|
||||
name: name === undefined ? undefined : escapeMessage(name),
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function serializeOutputs(nodeDef: LocalizableNodeDef) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap(({ name, tooltip }, index) => {
|
||||
const serializedName =
|
||||
name === undefined || name in dataTypes
|
||||
? undefined
|
||||
: escapeMessage(name)
|
||||
if (serializedName === undefined && tooltip === undefined) return []
|
||||
|
||||
return [[index.toString(), { name: serializedName, tooltip }]]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
function serializeWidgetLabels(nodeName: string) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [
|
||||
normalizeI18nKey(name),
|
||||
{
|
||||
name: label.name === undefined ? undefined : escapeMessage(label.name)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
const nodeDefinitions = Object.fromEntries(
|
||||
[...nodeDefs]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...serializeInputs(nodeDef),
|
||||
...serializeWidgetLabels(nodeDef.name)
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name),
|
||||
description: nodeDef.description
|
||||
? escapeMessage(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: serializeOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const nodeCategories = Object.fromEntries(
|
||||
nodeDefs.flatMap(({ category }) =>
|
||||
category
|
||||
.split('/')
|
||||
.map((part) => [normalizeI18nKey(part), escapeMessage(part)])
|
||||
)
|
||||
)
|
||||
|
||||
return { dataTypes, nodeCategories, nodeDefinitions }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
|
||||
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
|
||||
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
|
||||
* sync). `collect-i18n-node-defs.ts` escapes such values with
|
||||
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
|
||||
* output actually compiles and renders the original literal text.
|
||||
*/
|
||||
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
|
||||
const compile = (message: string) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
it.for([
|
||||
'clips (tagged @Audio1-3 in the prompt)',
|
||||
'support@comfy.org',
|
||||
'resolution {width}x{height}',
|
||||
'foreground | background',
|
||||
'50%{done}',
|
||||
'all of @ { } | % together',
|
||||
'no special chars here'
|
||||
])('renders %s as the original literal text', (raw) => {
|
||||
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { syncHostUserIdWithFirebaseAuth } from '@/platform/telemetry/hostUserIdSync'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
import { isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
@@ -141,6 +142,10 @@ app
|
||||
modules: [VueFireAuth()]
|
||||
})
|
||||
|
||||
if (isCloud && hasHostTelemetryBridge) {
|
||||
syncHostUserIdWithFirebaseAuth()
|
||||
}
|
||||
|
||||
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
|
||||
flushProxyWidgetMigration({
|
||||
hostNode,
|
||||
|
||||
@@ -7,8 +7,9 @@ import type {
|
||||
BeginCheckoutMetadata,
|
||||
DefaultViewSetMetadata,
|
||||
EnterLinearMetadata,
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionOutcomeMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
@@ -21,8 +22,6 @@ import type {
|
||||
PageVisibilityMetadata,
|
||||
ResubscribeClickMetadata,
|
||||
RunButtonProperties,
|
||||
ShareFlowMetadata,
|
||||
ShareLinkOpenedMetadata,
|
||||
SettingChangedMetadata,
|
||||
SharedWorkflowRunMetadata,
|
||||
ShellLayoutMetadata,
|
||||
@@ -269,10 +268,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackWorkflowExecution?.())
|
||||
}
|
||||
|
||||
trackExecutionOutcome(metadata: ExecutionOutcomeMetadata): void {
|
||||
this.dispatch((provider) => provider.trackExecutionOutcome?.(metadata))
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.dispatch((provider) => provider.trackExecutionError?.(metadata))
|
||||
}
|
||||
|
||||
183
src/platform/telemetry/hostUserIdSync.test.ts
Normal file
183
src/platform/telemetry/hostUserIdSync.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as VueModule from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
type MockAuthStore = {
|
||||
isInitialized: boolean
|
||||
currentUser: { uid: string } | null
|
||||
}
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
authStore: null as unknown as MockAuthStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', async () => {
|
||||
const { reactive } = await vi.importActual<typeof VueModule>('vue')
|
||||
hoisted.authStore = reactive<MockAuthStore>({
|
||||
isInitialized: false,
|
||||
currentUser: null
|
||||
})
|
||||
return { useAuthStore: () => hoisted.authStore }
|
||||
})
|
||||
|
||||
import { syncHostUserIdWithFirebaseAuth } from './hostUserIdSync'
|
||||
|
||||
const stopHandles: Array<() => void> = []
|
||||
|
||||
function installTelemetryBridge() {
|
||||
const reportFirebaseAuthState = vi.fn()
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
Telemetry: {
|
||||
capture: vi.fn(),
|
||||
reportFirebaseAuthState
|
||||
}
|
||||
}
|
||||
return { reportFirebaseAuthState }
|
||||
}
|
||||
|
||||
function startSync(): void {
|
||||
const stop = syncHostUserIdWithFirebaseAuth()
|
||||
if (stop) stopHandles.push(stop)
|
||||
}
|
||||
|
||||
describe('host user ID sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.authStore.isInitialized = false
|
||||
hoisted.authStore.currentUser = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (stopHandles.length) stopHandles.pop()?.()
|
||||
delete window.__comfyDesktop2
|
||||
})
|
||||
|
||||
it('waits for Firebase auth initialization before reporting a user', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'pending'
|
||||
})
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
|
||||
|
||||
hoisted.authStore.isInitialized = true
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_in',
|
||||
userId: 'firebase-user-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a restored Firebase session immediately', () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-a' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an initially signed-out Firebase session', () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.isInitialized = true
|
||||
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_out' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('reports signed out when Firebase finishes initialization', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
|
||||
|
||||
hoisted.authStore.isInitialized = true
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_out'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports account switches, logout, and subsequent login', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
startSync()
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-b' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_in',
|
||||
userId: 'firebase-user-b'
|
||||
})
|
||||
|
||||
hoisted.authStore.currentUser = null
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_out'
|
||||
})
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-c' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-a' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-b' }],
|
||||
[{ status: 'signed_out' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-c' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not report again when Firebase replaces the user object with the same UID', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
startSync()
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-a' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let a host reporting failure interrupt Firebase state sync', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
reportFirebaseAuthState.mockImplementationOnce(() => {
|
||||
throw new Error('host unavailable')
|
||||
})
|
||||
|
||||
expect(() => startSync()).not.toThrow()
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_in',
|
||||
userId: 'firebase-user-a'
|
||||
})
|
||||
})
|
||||
})
|
||||
49
src/platform/telemetry/hostUserIdSync.ts
Normal file
49
src/platform/telemetry/hostUserIdSync.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
function safelyReportFirebaseAuthState(report: () => void): void {
|
||||
try {
|
||||
report()
|
||||
} catch {
|
||||
// A host bridge failure must not block renderer startup or Firebase auth.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the Desktop main-process telemetry identity aligned with Firebase auth.
|
||||
* Must run after Pinia and VueFire are installed.
|
||||
*/
|
||||
export function syncHostUserIdWithFirebaseAuth(): WatchStopHandle | undefined {
|
||||
const telemetry = window.__comfyDesktop2?.Telemetry
|
||||
if (!telemetry) return
|
||||
|
||||
// Register this Cloud renderer before Firebase resolves. Desktop may host
|
||||
// multiple Cloud main frames whose isolated browser partitions have
|
||||
// different auth states, so main owns all cross-WebContents arbitration.
|
||||
safelyReportFirebaseAuthState(() =>
|
||||
telemetry.reportFirebaseAuthState?.({ status: 'pending' })
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
return watch(
|
||||
() =>
|
||||
authStore.isInitialized
|
||||
? (authStore.currentUser?.uid ?? null)
|
||||
: undefined,
|
||||
(userId) => {
|
||||
if (userId === undefined) return
|
||||
|
||||
safelyReportFirebaseAuthState(() =>
|
||||
telemetry.reportFirebaseAuthState?.(
|
||||
userId === null
|
||||
? { status: 'signed_out' }
|
||||
: { status: 'signed_in', userId }
|
||||
)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
@@ -28,8 +28,7 @@ export async function initTelemetry(): Promise<void> {
|
||||
{ PostHogTelemetryProvider },
|
||||
{ ClickHouseTelemetryProvider },
|
||||
{ SyftTelemetryProvider },
|
||||
{ CustomerIoTelemetryProvider },
|
||||
{ DatadogRumTelemetryProvider }
|
||||
{ CustomerIoTelemetryProvider }
|
||||
] = await Promise.all([
|
||||
import('./TelemetryRegistry'),
|
||||
import('./providers/cloud/MixpanelTelemetryProvider'),
|
||||
@@ -38,8 +37,7 @@ export async function initTelemetry(): Promise<void> {
|
||||
import('./providers/cloud/PostHogTelemetryProvider'),
|
||||
import('./providers/cloud/ClickHouseTelemetryProvider'),
|
||||
import('./providers/cloud/SyftTelemetryProvider'),
|
||||
import('./providers/cloud/CustomerIoTelemetryProvider'),
|
||||
import('./providers/cloud/DatadogRumTelemetryProvider')
|
||||
import('./providers/cloud/CustomerIoTelemetryProvider')
|
||||
])
|
||||
|
||||
const registry = new TelemetryRegistry()
|
||||
@@ -50,7 +48,6 @@ export async function initTelemetry(): Promise<void> {
|
||||
registry.registerProvider(new ClickHouseTelemetryProvider())
|
||||
registry.registerProvider(new SyftTelemetryProvider())
|
||||
registry.registerProvider(new CustomerIoTelemetryProvider())
|
||||
registry.registerProvider(new DatadogRumTelemetryProvider())
|
||||
|
||||
setTelemetryRegistry(registry)
|
||||
})()
|
||||
|
||||
@@ -1,26 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const analytics = {
|
||||
identify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
identify: vi.fn().mockResolvedValue(undefined),
|
||||
page: vi.fn(),
|
||||
track: vi.fn().mockResolvedValue(undefined),
|
||||
reset: vi.fn(),
|
||||
register: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
let resolvedCb: ((user: { id: string }) => void) | undefined
|
||||
let logoutCb: (() => void) | undefined
|
||||
const resolvedUserInfo = { value: null as { id: string } | null }
|
||||
return {
|
||||
analytics,
|
||||
load: vi.fn(() => analytics),
|
||||
inAppPlugin: vi.fn(() => ({ name: 'Customer.io In-App Plugin' })),
|
||||
userEmail: { value: null as string | null },
|
||||
onUserResolved: vi.fn((cb: (user: { id: string }) => void) => {
|
||||
resolvedCb = cb
|
||||
if (resolvedUserInfo.value) cb(resolvedUserInfo.value)
|
||||
}),
|
||||
onUserLogout: vi.fn((cb: () => void) => {
|
||||
logoutCb = cb
|
||||
}),
|
||||
resolveUser: (id: string) => resolvedCb?.({ id }),
|
||||
logoutUser: () => logoutCb?.()
|
||||
resolvedUserInfo,
|
||||
resolveUser: (id: string) => {
|
||||
resolvedUserInfo.value = { id }
|
||||
resolvedCb?.({ id })
|
||||
},
|
||||
logoutUser: () => {
|
||||
resolvedUserInfo.value = null
|
||||
logoutCb?.()
|
||||
},
|
||||
resetCallbacks: () => {
|
||||
resolvedCb = undefined
|
||||
logoutCb = undefined
|
||||
resolvedUserInfo.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -31,6 +47,8 @@ vi.mock('@customerio/cdp-analytics-browser', () => ({
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userEmail: hoisted.userEmail,
|
||||
resolvedUserInfo: hoisted.resolvedUserInfo,
|
||||
onUserResolved: hoisted.onUserResolved,
|
||||
onUserLogout: hoisted.onUserLogout
|
||||
})
|
||||
@@ -54,14 +72,32 @@ function createProvider(
|
||||
return new CustomerIoTelemetryProvider()
|
||||
}
|
||||
|
||||
function createDeferred() {
|
||||
let resolve = () => {}
|
||||
const promise = new Promise<void>((complete) => {
|
||||
resolve = complete
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('CustomerIoTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.resetCallbacks()
|
||||
hoisted.load.mockReturnValue(hoisted.analytics)
|
||||
hoisted.analytics.identify.mockResolvedValue(undefined)
|
||||
hoisted.analytics.track.mockResolvedValue(undefined)
|
||||
hoisted.analytics.reset.mockReset().mockResolvedValue(undefined)
|
||||
hoisted.analytics.register.mockResolvedValue(undefined)
|
||||
hoisted.userEmail.value = null
|
||||
window.__CONFIG__ = {} as typeof window.__CONFIG__
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('loads the client and registers the in-app plugin with the site id', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
@@ -73,6 +109,89 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
expect(hoisted.analytics.register).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the current page after registering the in-app plugin', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledWith()
|
||||
expect(hoisted.analytics.register.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.page.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('queues page views until the in-app plugin is registered', async () => {
|
||||
let resolveRegistration: (() => void) | undefined
|
||||
const registration = new Promise<void>((resolve) => {
|
||||
resolveRegistration = resolve
|
||||
})
|
||||
hoisted.analytics.register.mockReturnValue(registration)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
expect(hoisted.analytics.page).not.toHaveBeenCalled()
|
||||
|
||||
resolveRegistration?.()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
)
|
||||
})
|
||||
|
||||
it('reports client-side route changes', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.page).not.toHaveBeenCalled()
|
||||
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('continues tracking events and page views when the in-app plugin fails to register', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const registrationError = new Error('in-app setup failed')
|
||||
hoisted.analytics.register.mockRejectedValue(registrationError)
|
||||
const provider = createProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'execution_start',
|
||||
SOURCE
|
||||
)
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to initialize Customer.io in-app plugin:',
|
||||
registrationError
|
||||
)
|
||||
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:add_api_credit_button_clicked',
|
||||
SOURCE
|
||||
)
|
||||
)
|
||||
provider.trackPageView('settings', {
|
||||
path: 'https://cloud.comfy.org/settings'
|
||||
})
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not initialize without a write key', async () => {
|
||||
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
|
||||
await vi.dynamicImportSettled()
|
||||
@@ -89,13 +208,19 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
expect(hoisted.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('identifies the person by uid only on auth resolve', async () => {
|
||||
it('identifies the resolved user with uid and email traits', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.userEmail.value = 'user@example.com'
|
||||
hoisted.resolveUser('test-uid-7f3a9c')
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('test-uid-7f3a9c')
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
|
||||
'test-uid-7f3a9c',
|
||||
{ email: 'user@example.com' }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('identifies with the configured user_id override without waiting for auth', async () => {
|
||||
@@ -108,8 +233,54 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid')
|
||||
expect(hoisted.onUserResolved).not.toHaveBeenCalled()
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
|
||||
'forced-uid',
|
||||
undefined
|
||||
)
|
||||
expect(hoisted.onUserResolved).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies a restored session with the configured user id once', async () => {
|
||||
hoisted.userEmail.value = 'restored@example.com'
|
||||
hoisted.resolvedUserInfo.value = { id: 'resolved-uid' }
|
||||
|
||||
createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid', {
|
||||
email: 'restored@example.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('re-identifies with the configured user id after logout and re-login', async () => {
|
||||
createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
hoisted.userEmail.value = 'returning@example.com'
|
||||
hoisted.resolveUser('resolved-uid')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'forced-uid',
|
||||
{ email: 'returning@example.com' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies before flushing events buffered before the SDK loads', async () => {
|
||||
@@ -129,13 +300,99 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
expect(identifyOrder).toBeLessThan(trackOrder)
|
||||
})
|
||||
|
||||
it('restores the resolved user after flushing an older auth event', async () => {
|
||||
let activeUser: string | null = null
|
||||
const trackedUsers: Array<[string, string | null]> = []
|
||||
hoisted.analytics.identify.mockImplementation((userId: string) => {
|
||||
activeUser = userId
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.track.mockImplementation((event: string) => {
|
||||
trackedUsers.push([event, activeUser])
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.userEmail.value = 'current@example.com'
|
||||
hoisted.resolvedUserInfo.value = { id: 'current-uid' }
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'queued-uid',
|
||||
email: 'queued@example.com'
|
||||
})
|
||||
provider.trackWorkflowExecution()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify.mock.calls).toEqual([
|
||||
['current-uid', { email: 'current@example.com' }],
|
||||
['queued-uid', { email: 'queued@example.com' }],
|
||||
['current-uid', { email: 'current@example.com' }]
|
||||
])
|
||||
)
|
||||
expect(activeUser).toBe('current-uid')
|
||||
expect(trackedUsers).toEqual([
|
||||
['app:user_auth_completed', 'queued-uid'],
|
||||
['execution_start', 'current-uid']
|
||||
])
|
||||
})
|
||||
|
||||
it('resets identity after flushing auth for a signed-out user', async () => {
|
||||
let activeUser: string | null = null
|
||||
let trackedUser: string | null = null
|
||||
hoisted.analytics.identify.mockImplementation((userId: string) => {
|
||||
activeUser = userId
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.track.mockImplementation(() => {
|
||||
trackedUser = activeUser
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.reset.mockImplementation(() => {
|
||||
activeUser = null
|
||||
})
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'queued-uid',
|
||||
email: 'queued@example.com'
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled())
|
||||
expect(trackedUser).toBe('queued-uid')
|
||||
expect(activeUser).toBeNull()
|
||||
})
|
||||
|
||||
it('resets on logout', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
)
|
||||
})
|
||||
|
||||
it('continues tracking after reset fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.analytics.reset.mockRejectedValueOnce(new Error('reset failed'))
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'execution_start',
|
||||
SOURCE
|
||||
)
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to process Customer.io operation:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
const DIRECT_EVENTS: Array<{
|
||||
@@ -189,6 +446,7 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
invoke: (p) =>
|
||||
p.trackShareFlow({
|
||||
step: 'dialog_opened',
|
||||
share_id: 'share-1',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false
|
||||
}),
|
||||
@@ -209,10 +467,280 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
|
||||
invoke(provider)
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('awaits auth identification before tracking without raw identifiers', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com',
|
||||
share_id: 'share-1'
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('uid-1', {
|
||||
email: 'person@example.com'
|
||||
})
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{
|
||||
...SOURCE,
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'uid-1'
|
||||
}
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.track.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses matching resolved-user identification for auth delivery', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.userEmail.value = 'person@example.com'
|
||||
hoisted.resolveUser('uid-1')
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-1' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks auth before resetting identity on logout', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
const trackResult = createDeferred()
|
||||
let activeUser: string | null = null
|
||||
let trackedUser: string | null = null
|
||||
hoisted.analytics.identify.mockImplementationOnce((userId: string) => {
|
||||
activeUser = userId
|
||||
return identifyResult.promise
|
||||
})
|
||||
hoisted.analytics.track.mockImplementationOnce(() => {
|
||||
trackedUser = activeUser
|
||||
return trackResult.promise
|
||||
})
|
||||
hoisted.analytics.reset.mockImplementationOnce(() => {
|
||||
activeUser = null
|
||||
})
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
hoisted.logoutUser()
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled())
|
||||
expect(trackedUser).toBe('uid-1')
|
||||
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.reset.mock.invocationCallOrder[0]
|
||||
)
|
||||
trackResult.resolve()
|
||||
})
|
||||
|
||||
it('restores signed-out identity when auth delivery follows logout', async () => {
|
||||
let activeUser: string | null = null
|
||||
let trackedUser: string | null = null
|
||||
hoisted.analytics.identify.mockImplementation((userId: string) => {
|
||||
activeUser = userId
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.track.mockImplementation(() => {
|
||||
trackedUser = activeUser
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.reset.mockImplementation(() => {
|
||||
activeUser = null
|
||||
})
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.userEmail.value = 'person@example.com'
|
||||
hoisted.resolveUser('uid-1')
|
||||
hoisted.logoutUser()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(trackedUser).toBe('uid-1')
|
||||
expect(activeUser).toBeNull()
|
||||
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.reset.mock.invocationCallOrder[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('restores a configured identity after tracking auth with the Firebase uid', async () => {
|
||||
const provider = createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
hoisted.analytics.identify.mockClear()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'firebase-uid',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify.mock.calls).toEqual([
|
||||
['firebase-uid', { email: 'person@example.com' }],
|
||||
['forced-uid', undefined]
|
||||
])
|
||||
)
|
||||
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.identify.mock.invocationCallOrder[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reset identity when login resolution follows auth tracking', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' })
|
||||
hoisted.userEmail.value = 'person@example.com'
|
||||
hoisted.resolveUser('uid-1')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-1' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.reset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not stall later events when identification never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.analytics.identify.mockReturnValueOnce(new Promise(() => {}))
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' })
|
||||
provider.trackWorkflowExecution()
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }],
|
||||
['execution_start', SOURCE]
|
||||
])
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to identify Customer.io user:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks auth after identifying a user without an email', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'uid-without-email' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
|
||||
'uid-without-email',
|
||||
undefined
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-without-email' }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks auth without identifying when user_id is absent', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ method: 'google', email: 'person@example.com' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, method: 'google' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks auth after identification fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.analytics.identify.mockRejectedValueOnce(
|
||||
new Error('identify failed')
|
||||
)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-1' }
|
||||
)
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to identify Customer.io user:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes events buffered before load once, in order', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
@@ -227,6 +755,67 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for queued auth identification before later events', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
identifyResult.resolve()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }],
|
||||
['execution_start', SOURCE]
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('does not wait for event delivery before handing off later events', async () => {
|
||||
const trackResult = createDeferred()
|
||||
hoisted.analytics.track.mockReturnValueOnce(trackResult.promise)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['execution_start', SOURCE],
|
||||
['app:add_api_credit_button_clicked', SOURCE]
|
||||
])
|
||||
)
|
||||
trackResult.resolve()
|
||||
})
|
||||
|
||||
it('snapshots resolved user email before queued identification', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'blocking-uid' })
|
||||
hoisted.userEmail.value = 'first@example.com'
|
||||
hoisted.resolveUser('resolved-uid')
|
||||
hoisted.userEmail.value = 'second@example.com'
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'resolved-uid',
|
||||
{ email: 'first@example.com' }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('disables tracking when the SDK fails to load', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.load.mockImplementation(() => {
|
||||
@@ -253,12 +842,12 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
|
||||
await vi.waitFor(() =>
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to track Customer.io event:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to track Customer.io event:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { AnalyticsBrowser } from '@customerio/cdp-analytics-browser'
|
||||
import { omit, withTimeout } from 'es-toolkit'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import type { AuthUserInfo } from '@/types/authTypes'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
PageViewMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
TelemetryEventProperties,
|
||||
@@ -16,9 +19,17 @@ import type {
|
||||
|
||||
export const EVENT_SOURCE = 'web-sdk'
|
||||
|
||||
const SDK_OPERATION_TIMEOUT_MS = 10_000
|
||||
|
||||
interface QueuedEvent {
|
||||
event: string
|
||||
properties: Record<string, unknown>
|
||||
identity?: CustomerIoIdentity
|
||||
}
|
||||
|
||||
interface CustomerIoIdentity {
|
||||
userId: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +42,12 @@ interface QueuedEvent {
|
||||
export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
private analytics: AnalyticsBrowser | null = null
|
||||
private isEnabled = true
|
||||
private isPageViewTrackingReady = false
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private pageViewQueued = false
|
||||
private identifiedUser: CustomerIoIdentity | null = null
|
||||
private sessionIdentity: CustomerIoIdentity | null = null
|
||||
private operationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor() {
|
||||
const {
|
||||
@@ -39,6 +55,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
site_id: siteId,
|
||||
user_id: userIdOverride
|
||||
} = window.__CONFIG__?.customer_io ?? {}
|
||||
this.sessionIdentity = userIdOverride ? { userId: userIdOverride } : null
|
||||
if (!writeKey || !siteId) {
|
||||
this.isEnabled = false
|
||||
return
|
||||
@@ -47,7 +64,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
void import('@customerio/cdp-analytics-browser')
|
||||
.then(({ AnalyticsBrowser, InAppPlugin }) => {
|
||||
const analytics = AnalyticsBrowser.load({ writeKey })
|
||||
void analytics.register(
|
||||
const inAppRegistration = analytics.register(
|
||||
InAppPlugin({
|
||||
siteId,
|
||||
events: null,
|
||||
@@ -60,14 +77,40 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
this.analytics = analytics
|
||||
|
||||
const currentUser = useCurrentUser()
|
||||
if (userIdOverride) {
|
||||
void analytics.identify(userIdOverride)
|
||||
} else {
|
||||
currentUser.onUserResolved((user) => void analytics.identify(user.id))
|
||||
const identifyResolvedUser = (user: AuthUserInfo) => {
|
||||
const identity = {
|
||||
userId: userIdOverride || user.id,
|
||||
email: currentUser.userEmail.value || undefined
|
||||
}
|
||||
this.sessionIdentity = identity
|
||||
return this.enqueueOperation(() => this.identify(identity))
|
||||
}
|
||||
currentUser.onUserLogout(() => void analytics.reset())
|
||||
|
||||
this.flushQueue()
|
||||
if (userIdOverride && !currentUser.resolvedUserInfo.value) {
|
||||
void this.enqueueOperation(() =>
|
||||
this.identify({ userId: userIdOverride })
|
||||
)
|
||||
}
|
||||
currentUser.onUserResolved((user) => {
|
||||
void identifyResolvedUser(user)
|
||||
})
|
||||
currentUser.onUserLogout(() => {
|
||||
this.sessionIdentity = null
|
||||
void this.enqueueOperation(() => this.resetIdentity())
|
||||
})
|
||||
|
||||
void this.flushQueue()
|
||||
void inAppRegistration
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to initialize Customer.io in-app plugin:',
|
||||
error
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isPageViewTrackingReady = true
|
||||
this.flushPageView()
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load Customer.io:', error)
|
||||
@@ -76,32 +119,134 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
})
|
||||
}
|
||||
|
||||
private send(event: string, properties: Record<string, unknown>): void {
|
||||
void this.analytics?.track(event, properties)?.catch((error) => {
|
||||
console.error('Failed to track Customer.io event:', error)
|
||||
private enqueueOperation(
|
||||
operation: () => Promise<void> | void
|
||||
): Promise<void> {
|
||||
this.operationQueue = this.operationQueue.then(operation).catch((error) => {
|
||||
console.error('Failed to process Customer.io operation:', error)
|
||||
})
|
||||
return this.operationQueue
|
||||
}
|
||||
|
||||
private track(event: string, metadata?: TelemetryEventProperties): void {
|
||||
private async resetIdentity(): Promise<void> {
|
||||
this.identifiedUser = null
|
||||
const analytics = this.analytics
|
||||
if (!analytics) return
|
||||
await withTimeout(async () => {
|
||||
await analytics.reset()
|
||||
}, SDK_OPERATION_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private async restoreSessionIdentity(): Promise<void> {
|
||||
if (this.sessionIdentity) {
|
||||
await this.identify(this.sessionIdentity)
|
||||
} else {
|
||||
await this.resetIdentity()
|
||||
}
|
||||
}
|
||||
|
||||
private async identify(identity: CustomerIoIdentity): Promise<void> {
|
||||
const analytics = this.analytics
|
||||
if (!analytics) return
|
||||
|
||||
if (
|
||||
this.identifiedUser?.userId === identity.userId &&
|
||||
this.identifiedUser.email === identity.email
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.identifiedUser = identity
|
||||
try {
|
||||
await withTimeout(async () => {
|
||||
await analytics.identify(
|
||||
identity.userId,
|
||||
identity.email ? { email: identity.email } : undefined
|
||||
)
|
||||
}, SDK_OPERATION_TIMEOUT_MS)
|
||||
} catch (error) {
|
||||
this.identifiedUser = null
|
||||
console.error('Failed to identify Customer.io user:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private async send(
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
identity?: CustomerIoIdentity
|
||||
): Promise<void> {
|
||||
const analytics = this.analytics
|
||||
if (!analytics) return
|
||||
|
||||
if (identity) await this.identify(identity)
|
||||
|
||||
void analytics.track(event, properties).catch((error) => {
|
||||
console.error('Failed to track Customer.io event:', error)
|
||||
})
|
||||
|
||||
if (identity) await this.restoreSessionIdentity()
|
||||
}
|
||||
|
||||
private track(
|
||||
event: string,
|
||||
metadata?: TelemetryEventProperties,
|
||||
identity?: CustomerIoIdentity
|
||||
): void {
|
||||
if (!this.isEnabled) return
|
||||
const properties = { ...metadata, event_source: EVENT_SOURCE }
|
||||
if (this.analytics) {
|
||||
this.send(event, properties)
|
||||
void this.enqueueOperation(() => this.send(event, properties, identity))
|
||||
} else {
|
||||
this.eventQueue.push({ event, properties })
|
||||
this.eventQueue.push({ event, properties, identity })
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueue(): void {
|
||||
private async flushQueue(): Promise<void> {
|
||||
if (!this.analytics) return
|
||||
for (const { event, properties } of this.eventQueue) {
|
||||
this.send(event, properties)
|
||||
}
|
||||
const queue = this.eventQueue
|
||||
this.eventQueue = []
|
||||
await this.enqueueOperation(async () => {
|
||||
for (const { event, properties, identity } of queue) {
|
||||
await this.send(event, properties, identity)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private sendPageView(): void {
|
||||
void this.analytics?.page()?.catch((error) => {
|
||||
console.error('Failed to track Customer.io page view:', error)
|
||||
})
|
||||
}
|
||||
|
||||
private flushPageView(): void {
|
||||
if (!this.isPageViewTrackingReady || !this.pageViewQueued) {
|
||||
return
|
||||
}
|
||||
this.pageViewQueued = false
|
||||
this.sendPageView()
|
||||
}
|
||||
|
||||
trackPageView(_pageName: string, _properties?: PageViewMetadata): void {
|
||||
if (!this.isEnabled) return
|
||||
if (!this.isPageViewTrackingReady) {
|
||||
this.pageViewQueued = true
|
||||
return
|
||||
}
|
||||
this.sendPageView()
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.track(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
const identity = metadata.user_id
|
||||
? {
|
||||
userId: metadata.user_id,
|
||||
email: metadata.email || undefined
|
||||
}
|
||||
: undefined
|
||||
this.track(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
omit(metadata, ['email', 'share_id']),
|
||||
identity
|
||||
)
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
@@ -137,6 +282,6 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.track(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
this.track(TelemetryEvents.SHARE_FLOW, omit(metadata, ['share_id']))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DatadogRumTelemetryProvider } from './DatadogRumTelemetryProvider'
|
||||
|
||||
const addDurationVital = vi.fn()
|
||||
const getInternalContext = vi.fn()
|
||||
|
||||
function installDatadogRum(): void {
|
||||
Object.defineProperty(window, 'DD_RUM', {
|
||||
configurable: true,
|
||||
value: { addDurationVital, getInternalContext }
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
Reflect.deleteProperty(window, 'DD_RUM')
|
||||
})
|
||||
|
||||
describe('DatadogRumTelemetryProvider', () => {
|
||||
it('records a workflow vital with its duration and outcome', () => {
|
||||
installDatadogRum()
|
||||
getInternalContext.mockReturnValue({ view: { id: 'view-a' } })
|
||||
vi.spyOn(performance, 'now').mockReturnValue(142)
|
||||
|
||||
new DatadogRumTelemetryProvider().trackExecutionOutcome({
|
||||
startTime: 42,
|
||||
outcome: 'success'
|
||||
})
|
||||
|
||||
expect(getInternalContext).toHaveBeenCalledWith(42)
|
||||
expect(addDurationVital).toHaveBeenCalledWith('workflow_execution', {
|
||||
startTime: performance.timeOrigin + 42,
|
||||
duration: 100,
|
||||
context: {
|
||||
origin_view_id: 'view-a',
|
||||
outcome: 'success',
|
||||
product: 'cloud_generation'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing when Datadog RUM is unavailable', () => {
|
||||
expect(() =>
|
||||
new DatadogRumTelemetryProvider().trackExecutionOutcome({
|
||||
startTime: 42,
|
||||
outcome: 'failure'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { ExecutionOutcomeMetadata, TelemetryProvider } from '../../types'
|
||||
|
||||
interface DatadogRumDurationVitalOptions {
|
||||
startTime: number
|
||||
duration: number
|
||||
context?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface DatadogRumInternalContext {
|
||||
view?: { id?: string }
|
||||
}
|
||||
|
||||
interface DatadogRumClient {
|
||||
addDurationVital(name: string, options: DatadogRumDurationVitalOptions): void
|
||||
getInternalContext(startTime?: number): DatadogRumInternalContext | undefined
|
||||
}
|
||||
|
||||
interface WindowWithDatadogRum extends Window {
|
||||
DD_RUM?: DatadogRumClient
|
||||
}
|
||||
|
||||
function getDatadogRum(): DatadogRumClient | undefined {
|
||||
return (window as WindowWithDatadogRum).DD_RUM
|
||||
}
|
||||
|
||||
export class DatadogRumTelemetryProvider implements TelemetryProvider {
|
||||
trackExecutionOutcome({
|
||||
startTime,
|
||||
outcome
|
||||
}: ExecutionOutcomeMetadata): void {
|
||||
const rum = getDatadogRum()
|
||||
const originViewId = rum?.getInternalContext(startTime)?.view?.id
|
||||
rum?.addDurationVital('workflow_execution', {
|
||||
startTime: performance.timeOrigin + startTime,
|
||||
duration: performance.now() - startTime,
|
||||
context: {
|
||||
outcome,
|
||||
product: 'cloud_generation',
|
||||
...(originViewId && { origin_view_id: originViewId })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -156,11 +156,6 @@ export interface ExecutionErrorMetadata {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ExecutionOutcomeMetadata {
|
||||
startTime: number
|
||||
outcome: 'success' | 'failure'
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution success metadata
|
||||
*/
|
||||
@@ -637,7 +632,6 @@ export interface TelemetryProvider {
|
||||
|
||||
// Workflow execution events
|
||||
trackWorkflowExecution?(): void
|
||||
trackExecutionOutcome?(metadata: ExecutionOutcomeMetadata): void
|
||||
trackExecutionError?(metadata: ExecutionErrorMetadata): void
|
||||
trackExecutionSuccess?(metadata: ExecutionSuccessMetadata): void
|
||||
trackSharedWorkflowRun?(metadata: SharedWorkflowRunMetadata): void
|
||||
|
||||
@@ -47,6 +47,14 @@ const MockFormDropdownMenu = {
|
||||
'candidateLabel'
|
||||
],
|
||||
template: `<div class="mock-menu" data-testid="dropdown-menu" :data-candidate-index="candidateIndex" :data-candidate-label="candidateLabel ?? ''" :data-items="JSON.stringify(items)">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
@click="$emit('item-click', item, index)"
|
||||
>
|
||||
{{ item.label ?? item.name }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('search-enter')">Search enter</button>
|
||||
</div>`
|
||||
}
|
||||
@@ -79,6 +87,7 @@ interface MountDropdownOptions {
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<FormDropdownItem[]>
|
||||
multiple?: boolean | number
|
||||
selected?: Set<string>
|
||||
searchQuery?: string
|
||||
onUpdateSelected?: (selected: Set<string>) => void
|
||||
onUpdateIsOpen?: (isOpen: boolean) => void
|
||||
@@ -97,6 +106,7 @@ function mountDropdown(
|
||||
props: {
|
||||
items,
|
||||
multiple: options.multiple,
|
||||
selected: options.selected,
|
||||
searcher: options.searcher,
|
||||
searchQuery: options.searchQuery,
|
||||
'onUpdate:selected': options.onUpdateSelected,
|
||||
@@ -256,6 +266,40 @@ describe('FormDropdown', () => {
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps the selected item when it is clicked again in single-select mode', async () => {
|
||||
const onUpdateSelected = vi.fn()
|
||||
const onUpdateIsOpen = vi.fn()
|
||||
const item = createItem('image', 'photo.png')
|
||||
const { user } = mountDropdown([item], {
|
||||
selected: new Set([item.id]),
|
||||
onUpdateSelected,
|
||||
onUpdateIsOpen
|
||||
})
|
||||
await openDropdown(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: item.label }))
|
||||
|
||||
expect(onUpdateSelected).not.toHaveBeenCalled()
|
||||
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('replaces the selected item when a different item is clicked in single-select mode', async () => {
|
||||
const onUpdateSelected = vi.fn()
|
||||
const firstItem = createItem('first', 'first.png')
|
||||
const secondItem = createItem('second', 'second.png')
|
||||
const { user } = mountDropdown([firstItem, secondItem], {
|
||||
selected: new Set([firstItem.id]),
|
||||
onUpdateSelected
|
||||
})
|
||||
await openDropdown(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: secondItem.label }))
|
||||
|
||||
expect(onUpdateSelected).toHaveBeenCalledWith(new Set([secondItem.id]))
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not select when Enter is pressed with an empty search query', async () => {
|
||||
const onUpdateSelected = vi.fn()
|
||||
const { user } = mountDropdown(
|
||||
|
||||
@@ -265,8 +265,14 @@ function handleFileChange(event: Event) {
|
||||
|
||||
function handleSelection(item: FormDropdownItem, index: number) {
|
||||
if (disabled) return
|
||||
const itemIsSelected = internalIsSelected(item, index)
|
||||
if (isSingleSelect.value && itemIsSelected) {
|
||||
closeDropdown({ restoreFocus: true })
|
||||
return
|
||||
}
|
||||
|
||||
const sel = selected.value
|
||||
if (internalIsSelected(item, index)) {
|
||||
if (itemIsSelected) {
|
||||
sel.delete(item.id)
|
||||
} else {
|
||||
if (sel.size < maxSelectable.value) {
|
||||
@@ -281,7 +287,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
}
|
||||
selected.value = new Set(sel)
|
||||
|
||||
if (maxSelectable.value === 1) {
|
||||
if (isSingleSelect.value) {
|
||||
closeDropdown({ restoreFocus: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1662,7 +1662,6 @@ export class ComfyApp {
|
||||
// user switches tabs while the request is in flight.
|
||||
const queuedWorkflow = useWorkspaceStore().workflow
|
||||
.activeWorkflow as ComfyWorkflow
|
||||
const startTime = performance.now()
|
||||
const p = await this.graphToPrompt(this.rootGraph)
|
||||
const queuedNodes = collectAllNodes(this.rootGraph)
|
||||
try {
|
||||
@@ -1686,7 +1685,6 @@ export class ComfyApp {
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output),
|
||||
promptOutput: p.output,
|
||||
startTime,
|
||||
workflow: queuedWorkflow
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,11 +42,6 @@ export const useExtensionService = () => {
|
||||
await Promise.all(
|
||||
extensions
|
||||
.filter((extension) => !extension.includes('extensions/core'))
|
||||
.filter(
|
||||
(extension) =>
|
||||
__DISTRIBUTION__ !== 'cloud' ||
|
||||
extension !== '/extensions/cloud/rum.js'
|
||||
)
|
||||
.map(async (ext) => {
|
||||
try {
|
||||
await import(/* @vite-ignore */ api.fileURL(ext))
|
||||
|
||||
@@ -21,7 +21,6 @@ const {
|
||||
mockOpenWorkflows,
|
||||
mockShowTextPreview,
|
||||
mockTrackExecutionError,
|
||||
mockTrackExecutionOutcome,
|
||||
mockTrackExecutionSuccess,
|
||||
mockTrackSharedWorkflowRun
|
||||
} = await vi.hoisted(async () => {
|
||||
@@ -34,7 +33,6 @@ const {
|
||||
mockOpenWorkflows: shallowRef<{ path: string }[]>([]),
|
||||
mockShowTextPreview: vi.fn(),
|
||||
mockTrackExecutionError: vi.fn(),
|
||||
mockTrackExecutionOutcome: vi.fn(),
|
||||
mockTrackExecutionSuccess: vi.fn(),
|
||||
mockTrackSharedWorkflowRun: vi.fn()
|
||||
}
|
||||
@@ -94,7 +92,6 @@ vi.mock('@/platform/distribution/types', async () => ({
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackExecutionError: mockTrackExecutionError,
|
||||
trackExecutionOutcome: mockTrackExecutionOutcome,
|
||||
trackExecutionSuccess: mockTrackExecutionSuccess,
|
||||
trackSharedWorkflowRun: mockTrackSharedWorkflowRun
|
||||
})
|
||||
@@ -617,7 +614,6 @@ describe('useExecutionStore - workflowStatus', () => {
|
||||
nodes: ['1'],
|
||||
id: jobId,
|
||||
promptOutput: { '1': createPromptNode('Node', 'TestNode') },
|
||||
startTime: 42,
|
||||
workflow
|
||||
})
|
||||
}
|
||||
@@ -635,7 +631,6 @@ describe('useExecutionStore - workflowStatus', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
fireExecutionStart('job-1')
|
||||
|
||||
expect(mockTrackExecutionOutcome).not.toHaveBeenCalled()
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('running')
|
||||
})
|
||||
|
||||
@@ -653,11 +648,6 @@ describe('useExecutionStore - workflowStatus', () => {
|
||||
fireExecutionSuccess('job-1')
|
||||
|
||||
callStoreJob('job-1', workflowA)
|
||||
expect(mockTrackExecutionOutcome).toHaveBeenCalledOnce()
|
||||
expect(mockTrackExecutionOutcome).toHaveBeenCalledWith({
|
||||
startTime: 42,
|
||||
outcome: 'success'
|
||||
})
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
|
||||
})
|
||||
|
||||
@@ -666,10 +656,6 @@ describe('useExecutionStore - workflowStatus', () => {
|
||||
fireExecutionError('job-1')
|
||||
|
||||
callStoreJob('job-1', workflowA)
|
||||
expect(mockTrackExecutionOutcome).toHaveBeenCalledWith({
|
||||
startTime: 42,
|
||||
outcome: 'failure'
|
||||
})
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
|
||||
})
|
||||
|
||||
@@ -686,10 +672,6 @@ describe('useExecutionStore - workflowStatus', () => {
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionSuccess('job-1')
|
||||
|
||||
expect(mockTrackExecutionOutcome).toHaveBeenCalledWith({
|
||||
startTime: 42,
|
||||
outcome: 'success'
|
||||
})
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
|
||||
})
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ interface QueuedJob {
|
||||
* value is a boolean indicating if the node has been executed.
|
||||
*/
|
||||
nodes: Record<string, boolean>
|
||||
startTime?: number
|
||||
/**
|
||||
* The workflow that is queued to be executed
|
||||
*/
|
||||
@@ -178,19 +177,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
mutateStatus((m) => m.set(workflow, status))
|
||||
}
|
||||
|
||||
function trackExecutionOutcome(
|
||||
jobId: string,
|
||||
status: WorkflowExecutionStatus
|
||||
) {
|
||||
if (status === 'running') return
|
||||
const startTime = queuedJobs.value[jobId]?.startTime
|
||||
if (startTime === undefined) return
|
||||
useTelemetry()?.trackExecutionOutcome({
|
||||
startTime,
|
||||
outcome: status === 'completed' ? 'success' : 'failure'
|
||||
})
|
||||
}
|
||||
|
||||
function setWorkflowStatus(jobId: string, status: WorkflowExecutionStatus) {
|
||||
const workflow = jobIdToWorkflow.get(jobId)
|
||||
if (!workflow) {
|
||||
@@ -198,7 +184,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
return
|
||||
}
|
||||
applyWorkflowStatus(workflow, status)
|
||||
trackExecutionOutcome(jobId, status)
|
||||
}
|
||||
|
||||
function clearWorkflowStatus(workflow: ComfyWorkflow) {
|
||||
@@ -733,13 +718,11 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodes,
|
||||
id,
|
||||
promptOutput,
|
||||
startTime,
|
||||
workflow
|
||||
}: {
|
||||
nodes: string[]
|
||||
id: JobId
|
||||
promptOutput: ComfyApiWorkflow
|
||||
startTime?: number
|
||||
workflow: ComfyWorkflow
|
||||
}) {
|
||||
queuedJobs.value[id] ??= { nodes: {} }
|
||||
@@ -752,7 +735,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
...queuedJob.nodes
|
||||
}
|
||||
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
|
||||
queuedJob.startTime = startTime
|
||||
queuedJob.workflow = workflow
|
||||
if (workflow) jobIdToWorkflow.set(String(id), workflow)
|
||||
queuedJob.shareId = workflow?.shareId
|
||||
@@ -779,7 +761,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// Don't let a stale 'running' overwrite a terminal status already set.
|
||||
if (pending === 'running' && workflowStatus.value.has(workflow)) return
|
||||
applyWorkflowStatus(workflow, pending)
|
||||
trackExecutionOutcome(jobId, pending)
|
||||
}
|
||||
|
||||
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)
|
||||
|
||||
@@ -334,27 +334,6 @@ export default defineConfig({
|
||||
tailwindcss(),
|
||||
typegpuPlugin({}),
|
||||
comfyAPIPlugin(IS_DEV),
|
||||
{
|
||||
name: 'inject-cloud-rum',
|
||||
apply: 'build',
|
||||
transformIndexHtml(html) {
|
||||
if (DISTRIBUTION !== 'cloud') return html
|
||||
|
||||
return {
|
||||
html,
|
||||
tags: [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: {
|
||||
type: 'module',
|
||||
src: '/extensions/cloud/rum.js'
|
||||
},
|
||||
injectTo: 'head-prepend'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
// Exclude proprietary ABCROM fonts from non-cloud builds
|
||||
{
|
||||
name: 'exclude-proprietary-fonts',
|
||||
|
||||
Reference in New Issue
Block a user