Compare commits

..

93 Commits

Author SHA1 Message Date
snomiao
d4e5317e79 fix(agent): hide FAB in screenshots, SSR guard, and tail event forwarding
- Inject CSS to hide agent UI in all E2E screenshot tests; tag agent
  terminal tests with @agent to opt out of the hide (fixes 170+ CI
  screenshot failures caused by the FAB overlapping #graph-canvas)
- Guard localStorage access in resolveProbeBase() behind a typeof-window
  check so it doesn't throw in SSR/unit-test contexts (Copilot comment)
- Rebase onto sno-frontend-preview (3 upstream commits: API key test
  button, connection panel refactor, pr-badge i18n fix)
- Implement comfy-ai tail: useLocalBridge now watches agentStore.messages
  and agentStore.isStreaming, emitting SpaEvent frames to the daemon so
  `comfy-ai tail` streams live session activity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:43:34 +09:00
snomiao
8c6fdeae37 feat(agent): local bridge composable + pairing UI in settings
- useLocalBridge: WS singleton connects to ws://127.0.0.1:7437/spa on
  AgentRoot mount, relays send/eval/abort/ping from CLI to SPA runtime
- useBridgeStatus: read-only bridge state (connected, activePairCode)
  readable from any component without lifecycle side-effects
- AgentSettings: 'Local agent bridge' section shows connection dot +
  status text; 'Generate pair code' button sends pair-request to daemon
  and shows the comfy-ai pair ... command to run in terminal; clears
  automatically when CLI claims the code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:39:21 +09:00
snomiao
270ab4ffcb feat(agent): default model gpt-5.5 + get API key links in settings
Add links to OpenAI and OpenRouter key pages below the API key input so
users know where to obtain a key and how to configure the base URL for
non-OpenAI providers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:39:21 +09:00
snomiao
1feebde790 fix(agent): codex review fixes
- agentStore: useIDBKeyval is async; the previous code seeded `messages`
  with an empty snapshot then enabled the write-back watcher immediately,
  so any send before the IDB read settled would clobber stored history
  with []. Gate the watcher on `persisted.isFinished` and merge the
  hydrated entries when they arrive.
- FoldablePanel: dropping `/` from the shell-operator regex. The
  attachment flow prefills the composer with paths like /input/foo.png,
  and the previous regex routed those to direct exec instead of the LLM.
- coreutils grep: drain stdin eagerly so the no-match case can return
  exit code 1. The previous generator-based version always returned 0,
  breaking `grep ... && ...` and `grep ... || ...` decision flows the
  agent relies on.
- registrySearch: stop advertising a non-existent `install-pack`
  command. Direct the user to ComfyUI-Manager UI instead until a real
  install-pack shell command exists.
- agentTerminal.spec: rewrite for the textarea-based panel; the old
  `.xterm-rows` / `.xterm-helper-textarea` locators no longer resolve.
  Drops the Tab-completion test (useCompletion was deleted) and adds a
  Ctrl+O fold/unfold smoke test.
2026-05-02 17:39:21 +09:00
snomiao
68b477dbbc feat(agent): inline prompt — input flows as next scrollback line
Previously the textarea was a separate footer with its own border/bg,
breaking the terminal illusion. Move the prompt+input INTO the
scrollable container so it visually flows as the next line of
scrollback — same font, same color scheme as the user-message blocks,
no border, no background.

- Pending-asset chips now render inline just above the prompt within
  the same scroll container (drops the heavy footer chrome).
- Click any blank space in the scrollback focuses the input — like a
  real terminal where typing always lands at the prompt regardless of
  scroll position. Clicks on actual messages are left alone so text
  selection still works.
- The prompt scrolls with content; auto-scroll-to-bottom keeps it in
  view when new messages arrive.
2026-05-02 17:39:21 +09:00
snomiao
46f4ee9e9f feat(agent): foldable Vue terminal with Ctrl+O toggle
Replaces the xterm.js scrollback with a Vue-native terminal that can
fold tool-call output. Tool calls collapse to a one-line summary by
default — "$ <cmd>   N lines, exit M" with green/red exit-code
colour — and Ctrl+O (or ⌘+O) toggles fold/unfold across every block at
once. Click the block header to toggle a single tool call.

Why this trade-off:
- xterm.js is a character grid; folding requires a parallel logical
  model and a full re-render on every toggle. Vue components track
  semantic blocks natively, so fold state is just reactive data.
- Side benefits: per-block text selection/copy, native theming via
  Tailwind tokens, no ANSI cursor math, no @xterm/* deps in the agent
  bundle, easier to extend (per-tool icons, inline images, links).

Implementation notes:
- store.AgentMessage gains an optional 'tool' meta field carrying
  script/stdout/stderr/exitCode so the renderer doesn't have to parse
  the synthesized text summary back into structure.
- Global keydown listener with capture:true preempts the browser's
  default Open-File dialog before Ctrl+O reaches it.
- Drops src/agent/ui/XtermPanel.vue, useXtermReadline.ts, and
  shell/useCompletion.ts. The xterm package is still used elsewhere
  in the codebase (logs/CommandTerminal), so no dep removal.
2026-05-02 17:39:21 +09:00
snomiao
775f4b28ba feat(agent): registry + community code search commands
New shell commands so the agent can find custom nodes the user has not
yet installed, instead of giving up when local node-search is empty:

- node-search-registry <q>  fuzzy node-class search across all
                            published packs (Comfy Registry)
- pack-search <q>           pack-name/description search
- pack-info <pack_id>       list every node-class a pack provides
- comfy-codesearch <q>      full-text source search across the WHOLE
                            public ComfyUI community via cs.comfy.org
                            (catches unregistered repos)

Discovery escalation chain in the system prompt is now:
  node-search → node-search-registry → pack-search/info → comfy-codesearch
The agent must walk down before claiming a node is missing.
2026-05-02 17:39:21 +09:00
snomiao
edd2c3a248 feat(agent): read-before-write guardrail + don't-stop-too-early
Layer 1.5 guardrail: any script touching mutating commands (add-node,
connect, set-widget, clear-workflow, save-as, …) is rejected by the
shell runtime until at least one canvas-observing read (graph, node-
list, active-workflow, …) has run in the same turn. Stops the agent
from blindly overwriting an existing workflow it never inspected.

Auto-continue tweaks:
- PROMISSORY_PATTERN extended to catch stop-early phrases ("here's a
  plan", "next, I'll", "we can also add", "to build/finish/complete")
  that surfaced as premature exits in real sessions.
- MAX_AUTOCONTINUE 3 → 6 so multi-step builds (load → encode → sample →
  decode → save) finish without prematurely punting back to the user.
2026-05-02 17:39:21 +09:00
snomiao
5afcb892b8 feat(agent): per-session log files in dev
Vite dev plugin /__agent-log now writes one file per browser session
(tmp/agent-logs/<date>-<sessionId>.jsonl) instead of mixing all sessions
into a single daily log. The browser logger already attaches sessionId
to every entry; this just routes by it on the server side.

Restores build/plugins/agentLog.ts that was lost in an earlier squash.
2026-05-02 17:39:21 +09:00
snomiao
87b3f13f87 feat(agent): experimental in-browser ComfyAI agent
Squashes 60 incremental agent commits into one cohesive change after
prior history accumulated 119 unrelated upstream commits during a
bad rebase. Working tree restored from sno-agent@aec9f9380; new deps
added freshly (no stale lockfile entries); changeTracker API call
sites updated for current sno-frontend-preview (captureCanvasState →
checkState).

What's in this commit:

UI: src/agent/ui/{AgentRoot,AgentFab,XtermPanel,AgentSettings,
useXtermReadline}.vue
- Floating ComfyAI button + draggable xterm panel toggled via 'c'
  keybind; auto-focuses the terminal helper textarea on open.
- xterm-driven readline with Tab completion, Shift+Enter newline,
  Ctrl-A/E/U/K/L/C, history navigation, multiline buffers.
- Liquid-glass theme using Comfy design tokens.
- Settings panel auto-opens when no API key set; compact 3-field
  layout (API base / API key / model) + collapsed advanced section.

Session loop: src/agent/llm/session.ts, composables/useAgentSession.ts
- Vercel AI SDK streamText with run_shell as the only tool.
- IndexedDB-persisted message history (300-message cap), replays
  on reopen with a 'previous session' divider.
- Programmatic guardrails: PROMISSORY_PATTERN auto-continue,
  silent-fail auto-continue, fragile-shell-idiom blocklist
  (Layer 1), definitive-claim verifier registry (Layer 2 — orphans,
  missing-models, queue-state, pre-refusal, punt-to-user).
- Configurable baseURL for OpenRouter / local LLM proxies; default
  gpt-5.4 via OpenAI.

Shell runtime: src/agent/shell/
- POSIX-ish parser (shell-quote based), AsyncIterable pipes,
  redirection, &&/||/; sequencing.
- VFS: in-memory /tmp + UserdataVFS-backed /workflows.
- Coreutils + Comfy.* command dispatch + run-js fallback.

Commands: src/agent/shell/commands/
- comfy/comfyNs: registered command discovery + namespace dispatch.
- workflow: save-as / new-workflow / rename-workflow / clear-workflow
  --force / set-subgraph-{desc,aliases} (modal-bypass equivalents).
- nodeOps: node-search, add-node (smart placement), connect (auto-
  layout on link), disconnect, remove-node, layout, align-nodes,
  distribute-nodes, select, get-widget, toggle-panel.
- graph/state/execution/sweep: introspection + queue + sweep helpers.
- templates / images / install / validate / see: template loading,
  output→input copy, Manager model install, Gemini canvas vision.

Tests: browser_tests/tests/agentTerminal.spec.ts, plus
src/agent/**/*.test.ts unit tests.

Docs: docs/adr/0009-frontend-only-agent-and-local-agent-bridge.md
records the architectural choice + rejected alternatives.

i18n: minimal en additions in src/locales/en/main.json for
agent.{fab,input,panel,settings} namespaces.

Deps added: @ai-sdk/openai, ai, zod, shell-quote, idb-keyval,
@xterm/xterm, @xterm/addon-fit, es-toolkit, @types/shell-quote.

PR #11547 — experimental, draft, expect breaking changes.
Preview: https://pr-11547.comfy-ui.pages.dev/
2026-05-02 17:39:21 +09:00
snomiao
eba055befe fix(pr-badge): avoid i18n interpolation for backend URL to prevent HTML escaping
escapeParameter: true in i18n.ts caused slashes in URLs to render as
&#x2F; in the popover tooltip. Concatenate the label and URL directly
instead of passing the URL through t() interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 13:39:21 +09:00
snomiao
4c8e5ad797 refactor(connect): move API key input into connection status section
Contextually show the API key field only after backend is tested and
cloud API is detected, co-located with the cloud API info and the
"Generate an API key" link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:11:18 +09:00
snomiao
d3bd6f9f12 feat(connect): add API key test button and hide when --disable-api-nodes
- Add Test button for Comfy API Key input (validates against /customers endpoint)
- Show ✓/✗ status after test
- Update hint text: "Only needed for cloud-API nodes"
- Hide API key section when backend reports --disable-api-nodes in argv
- Show notice when API nodes are disabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 07:40:09 +09:00
snomiao
a785f72aa0 feat(connect): add optional API key input to connection panel
Users can now enter their Comfy API key on /connect before clicking
"Connect & Open ComfyUI". The key is saved to localStorage (comfy_api_key)
so it's picked up automatically after connecting — no need to re-enter it
in Settings → API Key.

Existing key is pre-filled from localStorage if already set.
Empty field on connect leaves any existing stored key unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 06:43:05 +09:00
snomiao
6130880992 fix(ci): use prod cloud API config for preview builds
Preview builds were using stagingapi.comfy.org (USE_PROD_CONFIG=false
default), causing a constant cloud environment mismatch warning for
every user who followed the quick-start guide — their local ComfyUI
backend defaults to api.comfy.org (production).

Setting USE_PROD_CONFIG=true aligns the preview with the default backend
behavior so the mismatch warning only fires when the user deliberately
passes --comfy-api-base=staging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 06:38:17 +09:00
snomiao
fb46996002 fix(topbar-badge): remove redundant tooltip text from full-mode popover
The tooltip string (with HTML-escaped URLs and dot-separated fields) was
showing inside the popover alongside the cleaner popoverLinks section.
Remove it — the links already convey all the same info without the HTML
entity artifacts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 05:15:43 +09:00
snomiao
bf11a90cd8 fix(topbar-badge): add popover to full display mode when popoverLinks set
Full mode only had v-tooltip (plain text on hover). When popoverLinks is
present, the badge now shows a clickable Popover with links, matching the
behavior of icon-only and compact modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 04:16:59 +09:00
snomiao
0c4861162d feat(pr-badge): show short commit hash in badge text alongside PR number
Badge now shows "PR #11118 · abc12345" so reviewers can see the exact
commit at a glance without opening the popover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 04:03:37 +09:00
snomiao
17f5bde180 feat(topbar-badge): add popoverLinks for clickable PR/commit/connect links
Extends TopbarBadge with a popoverLinks field that renders as <a> tags
in the popover. The PR preview badge now shows clickable links to the
GitHub PR page, author profile, commit, and a "Configure backend →"
link that re-opens /connect to change the backend URL at any time.

Also fixes prPreviewBadges backendUrl to read from localStorage directly
(shows '—' when no remote backend configured, instead of the CF Pages
domain itself).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 03:56:57 +09:00
snomiao
d840020427 fix: unexport DEFAULT_CLOUD_API_BASE (knip unused export)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
snomiao
1a993fc1dc feat(topbar): add PR preview badge with build provenance
- Add prPreviewBadges extension (active only when CI_PR_NUMBER is set):
  - Topbar warning badge showing "PR #XXXX", tooltip with author/commit/backend URL
  - About panel badges: clickable PR#, author (@handle), commit hash links
- Declare __CI_PR_NUMBER__, __CI_PR_AUTHOR__, __CI_BRANCH__, __CI_RUN_ID__,
  __CI_JOB_ID__, __COMFYUI_FRONTEND_COMMIT__ as global TS constants in vite-env.d.ts
- Extract resolveBackendCloudBase() to shared util at
  src/platform/connectionPanel/resolveBackendCloudBase.ts
- Add comfy_api_base as optional field in zSystemStats schema (companion to
  backend PR Comfy-Org/ComfyUI#13571)
- Add prPreview i18n keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
snomiao
321c32463e feat(connection-panel): consume comfy_api_base from system_stats directly
When the backend exposes `system.comfy_api_base` (added in companion
backend PR), use it directly instead of parsing it out of `argv`.
Falls back to argv parsing for older backends that don't yet include
the field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
GitHub Action
0a441ab896 [automated] Apply ESLint and Oxfmt fixes 2026-04-27 09:07:05 +09:00
snomiao
cf01213235 feat(connection-panel): add copy buttons to command blocks and expand local-network guidance
- Add CopyCodeBlock component with copy-to-clipboard and 2s "copied" checkmark feedback
- Replace all static <code> blocks in ConnectionPanelView with CopyCodeBlock
- Pre-compute launch command strings in script to avoid HTML entity escaping in template
- Expand localAccess section with --listen command for multi-device LAN testing
- Add corsOriginNote explaining why the exact origin matters (not *)
- Add i18n keys: corsOriginNote, localAccessListenDescription, localAccessListenNote

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:07:05 +09:00
snomiao
61bcf0a8bc feat(connection-panel): add preview-build warning aside with provenance
Adds a prominent amber aside under the panel title showing:
- This is an in-flight PR build, UI may change rapidly, not for production
- Build provenance: PR number, commit hash, author (all linked to GitHub)
- Trust warning: do not connect a backend you care about unless you trust
  the PR author, since a malicious frontend can read or modify any
  workflow / model / output on the connected backend.

Wires `CI_PR_AUTHOR` from `github.event.pull_request.user.login` through
the build env into a new `__CI_PR_AUTHOR__` define.
2026-04-27 09:07:05 +09:00
snomiao
0b90645d87 feat(connection-panel): link to API key page for the backend's cloud target
When the backend reports a known Comfy Cloud API base (prod or staging via
`--comfy-api-base`), surface a link to the matching platform's API-keys page
(platform.comfy.org or stagingplatform.comfy.org). Custom/unknown bases hide
the link rather than guess a URL.
2026-04-27 09:07:05 +09:00
snomiao
8f60294f63 feat(connection-panel): detect backend cloud API base from system_stats argv
Parse `--comfy-api-base` out of `/system_stats` `system.argv` and surface the
backend's Comfy Cloud target in the connection panel. When it disagrees with
the frontend's build-time `getComfyApiBaseUrl()`, show an amber warning so
users can spot the mismatch before sign-in tokens get rejected.

Records the cleaner long-term path (add `comfy_api_base` to the backend's
`/features` endpoint) in docs/backend-cloud-api-base-feature-flag.md.
2026-04-27 09:07:05 +09:00
snomiao
da8be4dc6c ci(preview): use sanitized branch name for cloudflare alias
Aligns frontend preview with storybook/playwright deploy scripts which
already key the cloudflare branch alias on the sanitized git branch name.
Falls back to pr-$PR_NUMBER when BRANCH_NAME is unset.
2026-04-27 09:07:05 +09:00
snomiao
3fa9c4522a refactor(connection-panel): combine comfy-cli install and comfy install into one step
uv pip install puts comfy on PATH immediately, so both commands
chain with && in the same shell. Collapses 4 quick-start steps to 3
and keeps the shell flow linear for copy-paste.
2026-04-27 09:07:04 +09:00
snomiao
2b675d6b5c fix: install pnpm in deploy-and-comment job so wrangler can run
The deploy script uses `pnpm dlx wrangler` when wrangler isn't on
PATH, but the deploy-and-comment job runs on a bare ubuntu image
with no pnpm — all three retry attempts fail with `pnpm: command
not found`. Set up pnpm + Node via the same actions setup-frontend
uses.
2026-04-27 09:07:04 +09:00
snomiao
66072fc4a6 fix: import cn from @comfyorg/tailwind-utils directly
The @/utils/tailwindUtil shim was removed from main in #11453; CI
merge-commit builds fail for this branch. Switch the import to the
package that replaced it. Both paths resolve locally today, so the
change is a drop-in.
2026-04-27 09:07:04 +09:00
snomiao
5f612e19b2 feat(connection-panel): promote ComfyUI-Manager in install guide
comfy install bundles ComfyUI-Manager by default. Surface that in
the quick start, plus an explanatory aside on why Manager smooths
the UX (one-click missing-model/custom-node install). For the
manual python alt path, add the git clone step explicitly.
2026-04-27 09:07:04 +09:00
snomiao
eb1fe9d88a fix: align backend probe base with ComfyApi for subpath deploys 2026-04-27 09:07:04 +09:00
snomiao
51e77c65ad chore: ignore runtime-generated scheduled_tasks.lock 2026-04-27 09:07:04 +09:00
snomiao
324d20477e refactor: drop unused branch_name parameter from preview deploy script 2026-04-27 09:07:04 +09:00
snomiao
6fb9915b45 chore: use pnpm dlx wrangler instead of npm/npx to match repo policy 2026-04-27 09:07:04 +09:00
snomiao
fed451edac fix: use BASE_URL for post-connect reload to support subpath deploys 2026-04-27 09:07:04 +09:00
snomiao
3ee55dfa1e fix: move page padding out of BaseViewTemplate into ConnectionPanelView 2026-04-27 09:07:04 +09:00
snomiao
93073cc242 fix: guard against malformed remote backend URL in localStorage 2026-04-27 09:06:08 +09:00
snomiao
5ef07f09fa chore: remove TODO.md planning file from repo 2026-04-27 09:06:07 +09:00
snomiao
2d5d77f7db fix: localize build metadata labels via vue-i18n
Move hardcoded English strings (PR #, Version:, Commit:, etc.)
in buildLabel and buildTooltip to i18n keys for consistency
with the rest of the connection panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
ef4d8622fa fix: scope CORS command to current preview origin
Replace wildcard * with window.location.origin so the
displayed command only opens the backend to this deployment,
not every origin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
dc2d8375fd fix: announce connection status to assistive technology
Add role="status" and aria-live="polite" to the connection
status section so screen readers announce test results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
bd0a10d7f0 test: clean up stubGlobal state between tests
vi.restoreAllMocks() does not undo vi.stubGlobal(), so stubs
like fetch and WebSocket leaked between tests. Add afterEach
with vi.unstubAllGlobals() and re-stub localStorage in
beforeEach for isolation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
5b75bb5bbf fix: use PR-scoped preview alias instead of branch normalization
Branch normalization collapsed distinct names (feature/foo,
feature_foo, feature-foo) to the same key, letting unrelated PRs
overwrite each other's preview. pr-$PR_NUMBER is unique per PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
8bdc850830 fix: add contents:read permission and serialize deploy comment jobs
comment-on-pr-start needs contents:read for checkout, and
deploy-and-comment must wait for it to avoid overwriting
the initial "Building..." comment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
95d0f22906 test: sync default.json fixture with defaultGraph models metadata
The locale-export test loads default.json then reloads the page
(which uses defaultGraph.ts). Since defaultGraph.ts now embeds
models metadata in the CheckpointLoader properties, the fixture
must match to avoid a mismatch between the English and Chinese
exports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
a53ea4dae2 fix: use exit 1 instead of return at top-level in deploy script
return is invalid outside a function/sourced-script context and
would mask the original wrangler installation failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
7ba5fcdf1a fix: use URL API for WebSocket URL construction in testWs
Parse base URL with new URL() instead of regex stripping so
paths/query/fragments in user-entered URLs cannot corrupt the
WebSocket endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
c176723bbc test: cover testConnection failure, URL normalization, and Connect button reveal
Adds three ConnectionPanelView tests for the previously uncovered branches:
- HTTP fetch failure renders an error message
- URLs entered without a protocol get http:// prepended
- Connect & Open ComfyUI button appears after a successful HTTP+WS test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:07 +09:00
snomiao
559001341a test: extract isBackendReachable from router and cover its branches
Codecov reported 0% coverage on the backend-probe logic embedded in
router.ts beforeEnter. The router module is hard to test in isolation
because importing it constructs the router and pulls in cloud/desktop
side effects. Extract the probe to a pure async function in
platform/connectionPanel/ and unit-test it: success, non-2xx, missing
system field, fetch rejection, trailing-slash normalization, and
unconfigured (same-origin) fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
b9c677f54c fix: embed model download metadata in defaultGraph CheckpointLoader
The hardcoded default workflow used on first load referenced
v1-5-pruned-emaonly-fp16.safetensors but had an empty properties object,
so the missing-model panel had no URL to offer for download. The version
in the workflow_templates package already includes properties.models with
the HuggingFace download URL — mirror it here so the Download button
appears when the checkpoint isn't installed locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
6304a60656 fix: don't use SPA route pathname as router base
When user lands directly on a SPA route like /connect, the previous logic
set the router base to /connect, causing the /connect route to map to
/connect/connect (and similarly for any deep-linked SPA route). A deploy
directory pathname always ends with /; SPA route pathnames don't. Use the
trailing slash as the discriminator: keep pathname as base when it ends
with / (reverse-proxy subpath case), otherwise fall back to BASE_URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
0b0a1076f4 fix: use grid instead of flex m-auto for BaseViewTemplate centering
Flexbox has a known gotcha where auto margins go negative when content
exceeds the container, pushing content off the top. CSS grid's
place-items-center handles overflow correctly — content centers when it
fits, aligns to top when it doesn't, and scrolls properly. Fixes /connect
still being unscrollable from the previous m-auto attempt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
d1968f2033 fix: allow BaseViewTemplate content to scroll when taller than viewport
Using items-center on a flex container with overflow-auto cuts off the
top of content that exceeds the viewport height because flex centering
doesn't interact with scrolling. Replace with an m-auto inner wrapper,
which still centers short content but lets tall content flow naturally
and scroll. Fixes the long /connect panel being unscrollable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
f129dc2aeb feat: recommend uv for Python setup in connection panel
Users without Python installed were getting stuck on pip install comfy-cli.
Restructure the Quick Start to install uv first (which is cross-platform,
a single-line shell install, and doesn't require Python itself to be
present). Then use uv pip install comfy-cli --system. Keep the plain
pip path in the collapsible Alternative section with a note about the
Python 3.10+ requirement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
1a4b766fb7 ci: prefer branch alias URL over hash URL in preview comment
Wrangler outputs both a unique-per-deployment hash URL and a stable
branch alias URL. The previous regex picked the first match which was
the hash, changing every deploy. Use the branch alias when present so
the PR comment link stays stable and human-readable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
3e8022c0d1 test: migrate ConnectionPanelView to @testing-library/vue
@vue/test-utils was removed from main as part of the test framework
migration. Migrate this test file to @testing-library/vue with
userEvent and Testing Library queries (getByRole, getByDisplayValue,
getByText) to satisfy the testing-library/no-node-access rule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
cb696d8426 fix: separate origin from base path so remote backend WS URL is valid
Previously, when a remote backend URL was set via the connection panel,
api_base contained the full origin and path, causing the WebSocket URL
to be malformed: wss://127.0.0.1:8188http://127.0.0.1:8188/ws.

Now we keep api_host/api_base as just host/path and use a separate
remoteOrigin field that gets prepended in apiURL/fileURL/internalURL.
The WebSocket protocol is also now derived from the remote backend URL
when set, instead of always copying from the page protocol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
3720ba3829 feat: auto-redirect to /connect when backend unreachable, add comfy-cli guide
When deployed to static hosting (Cloudflare Pages), the frontend now
detects that no backend is available and redirects to /connect instead
of hanging on "Loading ComfyUI". The connection panel includes comfy-cli
quick start guide, connection tester, and "Connect & Go" button.
API requests are routed to the user-configured remote backend URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:06:06 +09:00
snomiao
cdc834705a feat: deploy frontend preview to Cloudflare Pages with ConnectionPanel
- Add CI workflow to build and deploy to comfy-ui.pages.dev on every push/PR
- Add deploy script with auto PR comments following storybook pattern
- Add ConnectionPanelView at /connect with backend URL config, HTTP/WS test, CLI guide, build info
- Inject CI metadata (branch, PR#, run ID, job ID) as build-time defines
- Add i18n strings, route, unit tests (8/8 passing)

Amp-Thread-ID: https://ampcode.com/threads/T-019d7738-d170-7409-8699-23a55d8ad5e7
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 09:06:06 +09:00
Dante
177224452e test(assets): add E2E tests for media type filter (#10784)
## Summary

Add Playwright E2E tests for media type filter in assets sidebar.

## Changes

- Add `filterButton`, `filterCheckbox(label)`, `openFilterMenu()` to
`AssetsSidebarTab` fixture
- New `Assets sidebar - media type filter` describe block with 3 tests:
- Filter menu shows all 4 media type checkboxes (Image, Video, Audio,
3D)
  - Unchecking image filter hides image assets
  - Re-enabling filter restores hidden assets
- Mock jobs use distinct file extensions (.png, .mp4, .mp3) since
filtering is extension-based

## Review Focus

Tests tagged `@cloud` — filter button is gated behind `isCloud` in
`MediaAssetFilterBar.vue`.

Fixes #10780

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10784-test-assets-add-E2E-tests-for-media-type-filter-3356d73d3650810a8ecdd102e9f5b47e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-27 08:15:03 +09:00
Terry Jia
6bf75b4cf0 refactor(load3d): introduce ModelAdapter abstraction for the loader switch (#11627)
> Prerequisite work for improved PLY / 3D Gaussian Splatting support —
the per-format loader logic needs to live behind a stable seam before
splat-specific fixes (orientation, async-decoder waits, GPU dispose,
custom bounds) and capability-driven UX gating can be added without
touching `LoaderManager`'s switch every time.

## Summary

Pure refactor. Extracts the per-extension switch inside `LoaderManager`
into three `ModelAdapter` implementations and wires the manager to
dispatch through them. **No behavior change** — same loader code paths,
same outputs, same fallbacks. Sixth in the series splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**:
- `ModelAdapter.ts` (new): defines the `ModelAdapter` interface (`kind`,
`extensions`, `capabilities`, `load`), a `ModelLoadContext` that exposes
only the `SceneModelManager` surface adapters need (`setOriginalModel`,
`registerOriginalMaterial`, `standardMaterial`, `materialMode`), and a
shared `fetchModelData(path, filename)` helper.
- `MeshModelAdapter.ts` (new): owns `stl`, `fbx`, `obj`, `gltf`, `glb`.
Each branch is a 1:1 lift of the corresponding `case` from
`LoaderManager.loadModelInternal` on `main`.
- `PointCloudModelAdapter.ts` (new): owns `ply`. Includes the existing
`FastPLYLoader` / `PLYLoader` fallback and the `pointCloud` vs mesh
branching logic.
- `SplatModelAdapter.ts` (new): owns `spz`, `splat`, `ksplat`. Wraps the
`SplatMesh` in a `Group` exactly like the previous `loadSplat` did.
- `LoaderManager.ts`: now owns just an adapter array (default = the
three above) and a small dispatch path. `pickAdapter` matches by
extension and routes PLY → splat when the `Comfy.Load3D.PLYEngine`
setting is `sparkjs` (preserving the previous routing).
`getCurrentAdapter()` is the new public reader used by `Load3d`.
- `Load3d.isSplatModel` / `isPlyModel` now query
`loaderManager.getCurrentAdapter()?.kind` instead of doing
tree-introspection (`containsSplatMesh`) or `instanceof
THREE.BufferGeometry` checks. Same return values, decoupled from the
model shape.
- `LoaderManagerInterface` no longer exposes the per-format loader
fields (`gltfLoader`, `objLoader`, etc.); those are now
adapter-internal.
- `SceneModelManager` is **unchanged** in this PR. Its existing
`containsSplatMesh()` traversal and PLY material-mode rebuild stay put;
a follow-up PR refactors them once capability gating is in place.

## Review Focus

- **Loader equivalence**: the body of every `case` in `main`'s
`LoaderManager.loadModelInternal` is now in the corresponding adapter's
`load()` method. Easiest way to verify: diff `main`'s
`LoaderManager.loadModelInternal` against the four `load()` bodies and
confirm each branch's behavior (file fetch + parse + material wiring +
group wrapping) is byte-identical.
- **Dispatch parity**: `pickAdapter` produces the same routing as `main`
— extension match first, then the PLYEngine === 'sparkjs' override
hoisted up from inside the old `loadPLY`.
- **Capability fields are dormant**: the `ModelAdapterCapabilities`
record (`fitToViewer`, `materialModes`, `fitTargetSize`, …) is declared
on every adapter but **not consumed anywhere in this PR**.
SceneModelManager / Load3d / Load3DControls still read no capability
data. The follow-up PR turns these on.
- **`setOriginalModel` / `registerOriginalMaterial`**: adapters now go
through the `ModelLoadContext` getter rather than reaching into
`modelManager` directly. The context's `standardMaterial` and
`materialMode` are exposed via getters so a late-bound `materialMode` is
read at the actual call site, not snapshotted at context creation.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `ModelAdapter.ts` (new) | **100%** | **100%** | **100%** | **100%** |
| `MeshModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `PointCloudModelAdapter.ts` (new) | 97.22% | 61.11% | 75% | 97.22% |
| `SplatModelAdapter.ts` (new) | **100%** | **100%** | **100%** |
**100%** |
| `LoaderManager.ts` (modified) | **100%** | 91.17% | 86.66% | **100%**
|
| `Load3d.ts` (modified) | 6.63% | 0% | 13.68% | 6.7% |

All four new files are at or near 100% via dedicated unit tests for each
adapter (load happy path, error propagation, extension declarations,
capability shape). `LoaderManager.test.ts` exercises the dispatch logic
— extension matching, the `ply → splat` sparkjs override, the stale-load
discard, the load-context proxying — across 34 cases. The two changed
`Load3d.ts` methods (`isSplatModel`, `isPlyModel`) get dedicated tests
verifying they read the current adapter's `kind` and fall back to
`false` when none is loaded.

`Load3d.ts`'s overall 6.7% number is the pre-existing baseline — the
existing `Load3d.test.ts` covers façade methods via prototype injection
rather than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). PR-F's surface in
`Load3d.ts` is two method bodies, both covered by the new adapter-driven
kind queries test.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11627-refactor-load3d-introduce-ModelAdapter-abstraction-for-the-loader-switch-34d6d73d3650811b8a1ccc55b45100f2)
by [Unito](https://www.unito.io)
2026-04-26 18:32:51 -04:00
Christian Byrne
7a13340989 chore: add 301 redirects for old Framer case study URLs (#11654)
## Summary

Add 301 redirects for old Framer case study URLs to new `/customers/`
pages.

## Changes

- Add `redirects` config to `apps/website/astro.config.ts` mapping two
old Framer enterprise case study URLs to their new Astro customer pages

## Testing

### Automated

- Website build succeeds with redirect pages generated
- Lint, typecheck, and format checks pass

### E2E Verification Steps

1. Deploy to preview
2. Visit
`/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping`
— should 301 redirect to `/customers/moment-factory/`
3. Visit
`/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui`
— should 301 redirect to `/customers/series-entertainment/`

Fixes #11583

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11654-chore-add-301-redirects-for-old-Framer-case-study-URLs-34e6d73d36508187a386eed3e25cf1b2)
by [Unito](https://www.unito.io)
2026-04-26 15:13:32 -07:00
Terry Jia
1b07e82ff7 fix: resolve mesh widget thumbnails via asset preview API (#11538)
## Summary
The Load3d select-model widget was passing the raw .glb URL as the item
preview_url, which browsers can't render as an image, producing the
broken-image icon on cloud/local asset-enabled servers.

Resolve thumbnails lazily from the asset API using the preview_id link
(matching Media3DTop's behavior), look up by basename to stay consistent
with the write path in useLoad3d, and fall back to a 3D-box placeholder
when no preview exists yet.

## Screenshots
before
<img width="1112" height="1333" alt="image"
src="https://github.com/user-attachments/assets/a8fa88ad-ab82-4951-be03-d28111322e30"
/>

after
with asset-enable on BE

https://github.com/user-attachments/assets/34b416af-5729-4ad0-bf17-722461ffc659

without asset-enable on BE
<img width="1026" height="1201" alt="image"
src="https://github.com/user-attachments/assets/71fd463f-ca77-4d63-85ed-01261d032d53"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11538-fix-resolve-mesh-widget-thumbnails-via-asset-preview-API-34a6d73d365081d2aefac044dab0dfc3)
by [Unito](https://www.unito.io)
2026-04-26 18:08:30 -04:00
Terry Jia
9f4c54eb24 refactor: extract Load3d right-click guard to load3dContextMenuGuard (#11625)
## Summary

Pull the right-click vs right-drag detection out of `Load3d` into a
sibling helper. Mechanical refactor — no behavior change. Third of four
small PRs splitting up the [`remove-ply-3dgs-nodes-squashed`
mega-commit.](https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.)

## Changes

- **What**: New `load3dContextMenuGuard.ts` exports
`attachContextMenuGuard(target, onMenu, { isDisabled, dragThreshold })`.
It installs `mousedown` / `mousemove` / `contextmenu` listeners against
a single `AbortController` and returns one dispose function.
- `Load3d` now calls `attachContextMenuGuard(this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event), { isDisabled: () =>
this.isViewerMode })` and stores the returned disposer in a single
field. Drops four private fields (`rightMouseStart`, `rightMouseMoved`,
`dragThreshold`, `contextMenuAbortController`) plus the now-redundant
`showNodeContextMenu` private method.
- The 5px drag threshold and `isViewerMode` gating are preserved.

## Review Focus

- The three event handlers (`mousedown`, `mousemove`, `contextmenu`)
inside the new helper match the old inline implementations one-for-one —
same `e.button === 2` / `e.buttons === 2` checks, same call to
`exceedsClickThreshold`, same `preventDefault` + `stopPropagation`
ordering.
- `isDisabled: () => this.isViewerMode` replaces the inline `if
(this.isViewerMode) return` early-out — same gate, just lifted to a
callback.
- A single `AbortController.abort()` (in the returned disposer) replaces
the old four-field teardown in `Load3d.remove()`.
- 9 unit tests cover the helper: click vs drag distinction at the
threshold, drag-then-click reset, `isDisabled` short-circuit, and the
disposer detaching all three listeners.

## Coverage

| File | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| `load3dContextMenuGuard.ts` (new) | **100%** | **93.33%** | **100%** |
**100%** |
| `Load3d.ts` (modified) | 7.12% | 0% | 13.97% | 7.18% |

The single uncovered branch on `load3dContextMenuGuard.ts` (line 22) is
the default-parameter fallback for `dragThreshold` when the caller omits
it — `Load3d` always passes `{ isDisabled, dragThreshold: 5 }` through
`attachContextMenuGuard`'s second-arg destructure, so the default never
fires under the production call path. Adding a test that omits
`dragThreshold` would push it to 100%; left as-is to avoid a
change-detector test for a default value.

The `Load3d.ts` numbers are the pre-existing baseline on `main` —
`Load3d.test.ts` covers façade methods via prototype injection rather
than instantiating the class (the constructor needs
`THREE.WebGLRenderer`, which happy-dom can't provide). The
`initContextMenu` rewrite and the `remove()` teardown change both sit in
those same uninstantiated paths and rely on browser e2e for end-to-end
coverage, the same as before. Net: the click-vs-drag logic that
previously had no unit test is now ≥93% covered through the extracted
helper.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11625-refactor-extract-Load3d-right-click-guard-to-load3dContextMenuGuard-34d6d73d36508162aecef46553a3f50d)
by [Unito](https://www.unito.io)
2026-04-26 17:55:09 -04:00
Terry Jia
6f6fc88b0f test: add unit tests for MaskEditorContent main container (#11651)
## Summary

Add unit tests for `MaskEditorContent` (the mask editor's main
orchestration container), raising coverage from 0% to **94.11% / 83.72%
/ 83.33% / 94.11%** (statements / branches / functions / lines).

## Changes

- **What**: Add `src/components/maskeditor/MaskEditorContent.test.ts`
(12 tests) covering:
- **Mount**: keyboard listeners attached, ResizeObserver observes the
container, all 5 canvas refs assigned to the store before init runs.
- **Init flow**: `loader.loadFromNode` → `imageLoader.loadImages` →
`panZoom.initializeCanvasPanZoom` → `canvasHistory.saveInitialState` →
`brushDrawing.initGPUResources` → `initPreviewCanvas` chain runs in
order; child UI (`ToolPanel` / `PointerZone` / `SidePanel` /
`BrushCursor`) only renders after init succeeds; GPU preview canvas
resolution matches the mask canvas.
- **Init errors**: rejection from `loader.loadFromNode` or
`panZoom.initializeCanvasPanZoom` is caught, logged, and triggers
`dialogStore.closeDialog()`.
- **ResizeObserver**: callback invokes `panZoom.invalidatePanZoom()`
(captured the constructor argument to call it manually).
  - **Drag**: `Ctrl+drag` is preventDefault'd; plain drag is not.
- **Unmount**: cleanup runs `brushDrawing.saveBrushSettings`,
`keyboard.removeListeners`, `canvasHistory.clearStates`,
`store.resetState`, `dataStore.reset`.

## Review Focus

- Heavy mock surface (10 modules): the 3 stores, 5 composables, plus 4
child Vue components and `LoadingOverlay`. All mocks are `vi.hoisted`
module-level. `mockStore` is `reactive()` because the source mutates
`activeLayer` (visible in template binding), `maskCanvas`, etc.; the
rest are plain function bags.
- Child components are stubbed to bare `<div data-testid>` so init
reveal can be asserted via `screen.findByTestId(...)` without engaging
their real implementations (each has its own test file).
- `MockResizeObserver` captures the constructor callback in module-level
`lastResizeCallback`. The "invalidate on resize" test invokes it
manually with empty args — that's enough to exercise the source's `if
(panZoom) { await panZoom.invalidatePanZoom() }` branch since the
callback only consumes `panZoom` from closure.
- happy-dom doesn't propagate `ctrlKey` through the `DragEvent`
constructor, so the drag tests set it via `Object.defineProperty(event,
'ctrlKey', { value })` (same pattern used in `PointerZone.test.ts` for
wheel `clientX/Y`).
- 94.11% line coverage — the two uncovered blocks (`containerRef`
missing, canvas refs missing) are early-return error paths unreachable
when Vue successfully mounts; not worth constructing a fixture to
trigger.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` mocks reset via `beforeEach`,
`screen.findByTestId` for async render assertions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11651-test-add-unit-tests-for-MaskEditorContent-main-container-34e6d73d365081b38af2e057cb7daf9e)
by [Unito](https://www.unito.io)
2026-04-26 17:52:29 -04:00
Terry Jia
492bec28c8 test: add unit tests for TopBarHeader (#11650)
## Summary

Add unit tests for `TopBarHeader` mask editor dialog component, raising
coverage from 0% to **100%** across statements, branches, functions, and
lines.

## Changes

- **What**: Add `src/components/maskeditor/dialog/TopBarHeader.test.ts`
(17 tests) covering:
  - Localized title rendering.
  - Undo / Redo buttons forward to `store.canvasHistory.{undo,redo}`.
- Four transform buttons (rotate left / right, mirror horizontal /
vertical) call the matching `canvasTransform` action — parametrized via
`it.each`.
- All four transform error paths: rejected promise is caught, swallowed,
and logged with the right `[TopBarHeader] ... failed:` prefix.
- Invert calls `canvasTools.invertMask`; Clear calls both
`canvasTools.clearMask` and `store.triggerClear`.
- Save: hides brush, awaits `saver.save()`, closes the dialog on
success; switches button text to "Saving" while in-flight; restores
brush + button label and logs on save failure.
  - Cancel: closes the dialog with the `global-mask-editor` key.

## Review Focus

- All five composable / store dependencies are mocked at module level
via `vi.hoisted`: `useMaskEditorStore`, `useDialogStore`,
`useCanvasTools`, `useCanvasTransform`, `useMaskEditorSaver`. Only the
store needs `reactive()` (`brushVisible` flips during save flow); the
rest are plain function bags.
- `Button.vue` is stubbed to a thin `<button :disabled>` so role queries
(`getByRole('button', { name: ... })`) resolve cleanly without dragging
in the real UI button's classes / variants.
- Real i18n via `createI18n`. Icon buttons rely on `:title` for their
accessible name; text buttons rely on slot text — both are reachable
through `getByRole('button', { name: ... })`.
- The "Saving" text test uses an unresolved promise to keep the
in-flight state observable; `waitFor` + `void user.click(...)` lets us
assert without awaiting the click. The dangling promise resolves at the
end so vitest doesn't complain.
- `it.each` parametrizes the four transform success paths and
(separately) the four error paths, keeping the file tight.
- Style aligned with sibling tests: `should ...` naming, `describe`
grouped by feature, `vi.hoisted` for cross-test mocks, real i18n with
`createI18n`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11650-test-add-unit-tests-for-TopBarHeader-34e6d73d365081adab66e460cf56accb)
by [Unito](https://www.unito.io)
2026-04-26 17:51:44 -04:00
Comfy Org PR Bot
13b660a15b 1.44.10 (#11620)
Patch version increment to 1.44.10

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-26 05:36:11 +00:00
Terry Jia
b232831441 fix: stop duplicate node creation when dropping image on Vue nodes (#11541)
## Summary
handleDrop checked `handled === true` to gate stopPropagation, but
onDragDrop from useNodeDragAndDrop is async and always returns a
Promise, so the check never matched. The drop then bubbled to the
document handler in app.ts and spawned a new LoadImage node in addition
to the one that accepted the drop.

Updated onDragDrop to take an optional claimEvent flag — when true, it
calls preventDefault()/stopPropagation() synchronously inside the
handler, only after the sync acceptance check passes (valid files /
same-origin URI), and before any await.
The Vue node now just calls await node.onDragDrop(event, true).
Rejected payloads (cross-origin URI, files filtered out, no valid files)
skip the claim and bubble to the document fallback as before. The
remaining edge case is async URI fetch failures, which we can't
sync-detect without speculatively claiming.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/d79a5101-370b-4873-8365-5f9ce188731b



after


https://github.com/user-attachments/assets/8b787474-eab9-4060-8146-c4d8bb24ff9f

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11541-fix-stop-duplicate-node-creation-when-dropping-image-on-Vue-nodes-34a6d73d36508113b153e31768602933)
by [Unito](https://www.unito.io)
2026-04-25 20:51:39 -04:00
Dante
996e362ba6 test: add unit tests for form-dropdown internals (#11441)
## Summary

Adds 32 unit tests across 3 files covering the internals of the
FormDropdown family (input, filter, menu item). Part of a
widget-test-coverage sequence.

## Changes

- **What**:
- `FormDropdownInput.test.ts` (14) — placeholder vs selected-items
display, label preference, multi-item join, select-click emit,
file-input rendering/accept/multiple/disabled/upload event.
- `FormDropdownMenuFilter.test.ts` (8) — option rendering, v-model
update on click, single-option disabled state, import-button gating by
\`useModelUpload.isUploadButtonEnabled\` (mocked), \`showUploadDialog\`
invocation.
- `FormDropdownMenuItem.test.ts` (10) — label vs name preference,
img/video rendering by injected \`AssetKindKey\`, placeholder gradient,
list-small layout, click emits index, mediaLoad event, selection
indicator.

## Review Focus

- \`useModelUpload\` mocked at the module boundary with a dynamic import
of \`vue\` inside \`vi.mock\` (needed because \`vi.hoisted\` runs before
imports).
- \`AssetKindKey\` provided via \`global.provide\` using the
\`ComputedRef<AssetKind>\` shape.
- \`v-tooltip\` registered as a no-op directive to avoid render errors
in happy-dom.
- No changes to any source component.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11441-test-add-unit-tests-for-form-dropdown-internals-3486d73d3650813cb4a1c6568280ef1a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-26 00:36:25 +00:00
Terry Jia
b0fa179b87 refactor: extract Load3d render loop to load3dRenderLoop (#11623)
## Summary

Pull the `requestAnimationFrame` loop and its activity-gated tick body
out of `Load3d` into a small `startRenderLoop({ tick, isActive })`
helper. Pure mechanical refactor — no behavior change. First of four
small PRs splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**: New `load3dRenderLoop.ts` exports `startRenderLoop` (returns
a `{ stop }` handle). `Load3d.startAnimation()` now constructs a loop
through it; `Load3d.remove()` calls `stop()` instead of
`cancelAnimationFrame`. Field `animationFrameId: number | null` becomes
`renderLoop: RenderLoopHandle | null`.

## Review Focus

- The tick body inside `startAnimation()` is byte-identical to the
previous inline body — only the rAF scheduling has moved.
- `isActive()` is now invoked through a `() => this.isActive()` closure
instead of a direct call inside the inline `animate` function, so the
activity check still fires once per frame and reads the same fields.
- The new helper has 4 unit tests covering: ticks while active, skip
while inactive, stop halts ticks, stop is idempotent.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11623-refactor-extract-Load3d-render-loop-to-load3dRenderLoop-34d6d73d3650815c9c4ec7713e912e37)
by [Unito](https://www.unito.io)
2026-04-25 20:03:01 -04:00
Terry Jia
ba6dd2a09c refactor(load3d): extract viewport math to load3dViewport (#11624)
## Summary

Pull two pure helpers out of `Load3d` into a sibling module. Mechanical
refactor — no behavior change. Second of four small PRs splitting up the
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495.

## Changes

- **What**: New `load3dViewport.ts` exports two pure functions:
- `computeLetterboxedViewport({ width, height }, targetAspectRatio)`
returns `{ offsetX, offsetY, width, height }` — the aspect-ratio fitting
math that was duplicated inline in three places (`renderMainScene`,
`setBackgroundImage`, `handleResize`).
- `isLoad3dActive(flags)` consumes the activity-flag struct used by the
rAF tick gate; `Load3d.isActive()` now delegates to it.

## Review Focus

- The three call sites in `Load3d.ts` produce byte-identical viewport
rectangles for any `(containerWidth, containerHeight, targetAspect)`
triple — same branch on aspect-ratio comparison, same offset derivation.
Simplest way to verify: diff the math.
- `isLoad3dActive` ORs the same six flags as before in the same order.
Old implementation read `this.STATUS_MOUSE_ON_NODE` etc. directly; new
one reads them through a flags object built at the call site.
- 13 unit tests cover the helpers: the "wider" / "taller" / "matching"
aspect cases for letterboxing, and each individual flag flipping
`isLoad3dActive` on by itself.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11624-refactor-load3d-extract-viewport-math-to-load3dViewport-34d6d73d365081ab9af5fc445bf5bd5a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-25 17:48:44 -04:00
Kelly Yang
4a9001f675 test: add E2E tests for image crop widget Levels 4-7 (#11571)
## Summary

Adds 12 Playwright E2E tests for the `ImageCropV2` widget covering
aspect ratio selection, lock/unlock behavior, constrained resize, and
BoundingBox numeric input — all of which had zero test coverage.

## Changes

**Level 4 — Aspect Ratio Selection** (`with source image after
execution`)
- Selecting 16:9 preset adjusts crop height proportionally via
`applyLockedRatio`
- Selecting Custom unlocks the ratio and restores all 8 resize handles

**Level 5 — Lock/Unlock** (`without source image` + `with source image
after execution`)
- Selecting a preset auto-enables the lock (aria-label changes to
"Unlock aspect ratio")
- Unlocking after a preset reverts the dropdown display to "Custom"
- Full lock→unlock round-trip verifies handle count (4 → 8) and
aria-label on both transitions

**Level 6 — Constrained Resize** (`with source image after execution`)
- NW corner drag grows origin (x, y decrease) and dimensions while
maintaining ratio
- SE corner drag beyond image edge clamps to boundary
- NW corner drag beyond (0, 0) clamps x/y to image boundary
- Inward SE corner drag enforces `MIN_CROP_SIZE` (16px minimum)

**Level 7 — BoundingBox Numeric Input** (`with source image after
execution`)
- X increment button increments crop x by 1
- Width increment button increments crop width by 1
- BoundingBox inputs reflect updated position after a drag

No source code was modified.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes are limited to Playwright E2E coverage plus adding
`data-testid` attributes to BoundingBox inputs, with no behavioral logic
changes expected.
> 
> **Overview**
> **Expands E2E coverage for the `ImageCropV2` widget** by adding new
Playwright tests for ratio preset selection, lock/unlock behavior,
constrained resizing (including boundary clamping and min size), and
BoundingBox numeric input updates.
> 
> **Improves testability of BoundingBox controls** by adding
`data-testid` attributes to the `WidgetBoundingBox.vue`
`ScrubableNumberInput` fields (`x`, `y`, `width`, `height`) so E2E tests
can target increment/input elements reliably.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b008f42942. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11571-test-add-E2E-tests-for-image-crop-widget-Levels-4-7-34b6d73d365081c79118ca9ae08f291c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-04-25 00:17:12 -04:00
Dante
9cf035879f test: add WidgetCurve unit tests (#11469)
## Summary

Splits the WidgetCurve test coverage out of #11446 so this widget can be
reviewed independently.

## Changes

- **What**: Adds WidgetCurve unit tests covering point forwarding,
interpolation updates, disabled-state behavior, and upstream value
handling.

## Review Focus

Focused test-only PR extracted from #11446.
Validated with `pnpm test:unit -- --run
src/components/curve/WidgetCurve.test.ts`.

## Screenshots (if applicable)

N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11469-test-add-WidgetCurve-unit-tests-3486d73d365081c2a68bc8403fa0265f)
by [Unito](https://www.unito.io)
2026-04-24 17:47:54 -07:00
Alexander Brown
d0e9984a73 feat: update BYOKeySection images to enterprise node WebPs (#11614)
Update BYOKeySection card images from placeholder API logos to dedicated
enterprise node WebP images hosted on media.comfy.org.

## Changes
- Replace `logo-purple.webp` and `logo-yellow.webp` with
`enterprise_node_1.webp` and `enterprise_node_2.webp`
- New images are near-lossless WebP (~70% smaller than source PNGs)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11614-feat-update-BYOKeySection-images-to-enterprise-node-WebPs-34c6d73d365081239d92c649eb563b7e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 20:03:21 +00:00
pythongosssss
125c11b3d0 fix: translate blueprint label (#11573)
## Summary

Fixes hardcoded "Blueprint" text and adds e2e coverage to test
visibility, resolving #11473

## Changes

- **What**: 
- add translation
- add test

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11573-fix-translate-blueprint-label-34b6d73d365081009215e06be6aa1fa0)
by [Unito](https://www.unito.io)
2026-04-24 19:27:47 +00:00
Alexander Brown
725ed120e8 fix: disable parallax on mobile to prevent enterprise section overlap (#11609)
## Summary

Disable parallax on mobile in the enterprise DataOwnershipSection to
prevent images from overlapping the next section.

## Changes

- **What**: Add `mediaQuery` option to `useParallax` composable, using
GSAP's `matchMedia()` to create/revert animations responsively.
DataOwnershipSection now only applies parallax at the `lg` (1024px+)
breakpoint.

## Review Focus

GSAP `matchMedia` automatically reverts animations (resetting
transforms) when the query stops matching, so no manual cleanup is
needed on resize.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11609-fix-disable-parallax-on-mobile-to-prevent-enterprise-section-overlap-34c6d73d365081a48d55e4cf880e3bab)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 12:13:00 -07:00
Alexander Brown
453a0edd1e chore: refresh Ashby careers snapshot (#11611)
## Changes

Refreshes the Ashby careers snapshot (`ashby-roles.snapshot.json`).

The "Business" department has been renamed to "Operations" on Ashby's
side. The same 3 roles (Senior Technical Recruiter, BizOps Strategist,
Founding Customer Success Manager) now appear under "Operations".

## Testing

- Snapshot was generated via `pnpm --filter @comfyorg/website
ashby:refresh-snapshot` which validates the API response through Zod
schemas before writing.
- Lint-staged checks (typecheck, eslint, oxlint, oxfmt) passed on
commit.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11611-chore-refresh-Ashby-careers-snapshot-34c6d73d3650813ea289c3c0371f882b)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 12:11:13 -07:00
Christian Byrne
5a8ded7959 Website: pull careers page listing from ashby API (#11590)
Careers

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-24 11:51:43 -07:00
Alexander Brown
bb23b9352c fix: update enterprise hero SVG to match updated design (#11608)
## Changes

Update the enterprise hero SVG in `HeroSection.vue` to match the updated
design export.

### Key changes

- **viewBox**: `600 -50 1000 1100` → `0 0 1600 1046` with `clip-path`
wrapper and background rects for proper clipping
- **Block pieces**: stroke/stroke-width moved from parent `<g>` to each
individual `<path>`, with duplicated paths for layered rendering
- **Block cluster**: wrapped in `.block-cluster` group with its own CSS
transform-origin
- **Ripple delay classes**: renamed `ripple-delay-*` → `delay-*`
- **Fade overlay**: added `pointer-events: none` to prevent blocking
interactions

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 10:35:24 -07:00
Alexander Brown
25f0b41f63 Remember what was forgotten (#11603)
## Summary

Every page has a story to tell.

## Changes

- **What**: Something was missing. Now it isn't.

## Review Focus

Look closely at what was lost.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11603-Remember-what-was-forgotten-34c6d73d36508184b3cef39d0be4a3bd)
by [Unito](https://www.unito.io)
2026-04-24 10:28:32 -07:00
Alexander Brown
e7673fcca7 update: API page links to keys and docs (#11606)
## Summary

Update API page CTA buttons to link directly to the API keys page and
API docs instead of generic platform/cloud/docs links.

## Changes

- **What**: Point "Get API Keys" buttons in HeroSection and StepsSection
to `platform.comfy.org/profile/api-keys`; point "Read the Docs" button
to `docsApi` route; add `apiKeys` entry to `externalLinks` config.

## Review Focus

Straightforward link target updates — verify the new URLs are correct.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11606-update-API-page-links-to-keys-and-docs-34c6d73d365081268bb5dd55c33646c8)
by [Unito](https://www.unito.io)
2026-04-24 10:28:03 -07:00
Yourz
7038cab926 update: replace placeholder images in API automation section (#11602)
## Changes

- Replace placeholder images in API automation section with final assets
  - Feature 1: `desert.webp` → `precision-tools.webp`
  - Feature 3: `free.webp` → `infrastructure-nodes.webp`


<img width="1000" height="552" alt="Kapture 2026-04-25 at 00 54 23"
src="https://github.com/user-attachments/assets/dd503d2f-56c3-4346-adfa-27b3b92a04a8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11602-update-replace-placeholder-images-in-API-automation-section-34c6d73d365081319ca4db9b60099835)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 16:02:36 +00:00
Yourz
1f888de0f6 update: content in Price page (#11600)
## Summary

<!-- One sentence describing what changed and why. -->

Update wrong content in Price page of new website

## Changes

- **What**: <!-- Core functionality added/modified -->
- update both English and Chinese content of translate key
`pricing.included.feature2.description`

## Review Focus

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

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

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->
<img width="1046" height="147" alt="image"
src="https://github.com/user-attachments/assets/0f2dc66c-d384-4fb4-85cb-2d9f68469dbc"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11600-update-content-in-Price-page-34c6d73d365081bb9ae1e09842c77249)
by [Unito](https://www.unito.io)
2026-04-24 15:59:18 +00:00
Yourz
b7a8056ab0 update: local hero illustration stroke colors and fix overflow clipping (#11601)
## Changes

- Update SVG stroke color from `#7E7C78` to `#4D3762` to match design
spec
- Reduce hex node stroke width from 6 to 3
- Fix illustration bottom edge being hard-clipped by removing
`lg:overflow-y-clip` from section
- Adjust container aspect ratio to match SVG viewBox proportions


<img width="1000" height="1287" alt="Kapture 2026-04-25 at 00 41 16"
src="https://github.com/user-attachments/assets/a3a14fba-74b3-4051-ac12-c175c4b3bd61"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11601-update-local-hero-illustration-stroke-colors-and-fix-overflow-clipping-34c6d73d3650811e9f0bdc1a3274800e)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 15:57:11 +00:00
pythongosssss
40fec7882c chore: remove unused glslUtils helpers (#11597)
## Summary

These look to have been added as part of the initial phased
implementation to align with the backend code, however they are unused:
- `detectOutputCount` - The frontend only shows a single output preview
which imo is fine, we can add multi display in future if required
- `hasVersionDirective` - The backend needs this as it can execute other
version shaders, the web browser will automatically validate and throw
an error if it is not a valid shader, this gets surfaced to the user
already.

## Changes

- **What**:  
- remove unused functions & tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11597-chore-remove-unused-glslUtils-helpers-34c6d73d365081eab784d205b8a0053e)
by [Unito](https://www.unito.io)
2026-04-24 10:52:45 +00:00
jaeone94
17d980dbc8 feat: add inline-CTA nightly survey for error panel (#11591)
## Summary

Adds an inline-CTA Typeform survey for the redesigned error panel,
targeting nightly localhost users. Reuses the existing node-search
survey infrastructure rather than introducing a parallel stack.

## Changes

- **What**:
- `surveyRegistry` gains optional `presentation: 'floating' |
'inline-cta'` and a `getFloatingSurveys()` helper; controller filters by
it.
- `NightlySurveyPopover` accepts `mode` + `v-model:open`. Manual mode
skips the eligibility watcher, drops the "Not Now" button, and leaves
open/close/markSeen to the parent.
- New `ErrorPanelSurveyCta.vue` renders a CTA in the error tab footer
once `useExecutionErrorStore.hasAnyError` has transitioned `null →
value` at least 3 times.
- Popover lives in `NightlySurveyController` (session-persistent).
Shared state via module-level singleton (`useErrorSurveyPopoverState`)
so the iframe survives error-tab unmounts during workflow switches.
- `useTypeformEmbed` centralises script loading (singleton Promise, 10s
timeout, explicit `window.tf.load()` on each new container). Necessary
because the embed's DOMContentLoaded auto-scan only fires once; late
consumers need an explicit re-scan to render.
  - CTA and feedback-gate strings added under `errorPanelSurvey.*`.

## Review Focus

- Manual-mode flow in `NightlySurveyPopover.vue` — CTA click is routed
through the module singleton; popover stays mounted after the first open
(`hasOpenedOnce` + `v-show`) to preserve the iframe across repeated
open/close cycles and workflow switches.
- `useTypeformEmbed.ts` — the `window.tf.load()` trick (verified against
the CDN `embed.js`) is what lets two surveys coexist; without it
Typeform's one-shot DOM scan misses whichever element mounts second.
- `NightlySurveyController.vue` guards against double-mount by requiring
`presentation === 'inline-cta'` on `errorPanelConfig`.
- Scope is nightly+localhost only (`isNightlyLocalhost` in
`useSurveyEligibility`); async component gate in `TabErrors.vue` keeps
this out of Cloud/Desktop/stable bundles.

## Test plan

- [x] `IS_NIGHTLY=true pnpm dev`, clear `Comfy.SurveyState` +
`Comfy.FeatureUsage`, trigger 3 failed runs → CTA appears in error tab
footer.
- [x] Click "Give feedback" → Typeform popover opens and renders the
form.
- [x] Close popover, switch workflow (error tab unmounts), trigger a new
error → CTA reappears and reopening the popover shows the same iframe.
- [x] Open error-panel popover, close, then trigger 3 node searches →
node-search auto-popup renders its own iframe correctly (two surveys
coexist).
- [x] CTA × dismisses and persists (`seenSurveys['error-panel']`).
- [x] "Don't ask again" inside popover sets `optedOut: true` and hides
all nightly surveys.
- [x] Cloud/Desktop/stable builds: CTA never renders, controller's
manual popover doesn't mount.

## Screenshot


https://github.com/user-attachments/assets/91145f23-fd1e-4caf-b6cc-4b97d33ed6b7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11591-feat-add-inline-CTA-nightly-survey-for-error-panel-34c6d73d3650817d9f95fddcf64633de)
by [Unito](https://www.unito.io)
2026-04-24 08:14:08 +00:00
Alexander Brown
9cd36c7f7d fix: website polish — prefetch, Safari video controls, border-spin perf (#11586)
## Summary

Website polish: enable Astro prefetch, fix mobile Safari video controls,
and optimize the ProductShowcase border-spin animation.

## Changes

- **What**:
- Enable `prefetch: { prefetchAll: true }` in Astro config for faster
navigation
- Hide native iOS Safari play-button overlays on autoplay background
videos (`BlobMedia`, global CSS)
- Refactor `ProductShowcaseSection` border-spin animation: use
`@property --border-angle` with `conic-gradient` directly on the
container (removes extra spinning `div`), gate animation on
`useIntersectionObserver` visibility, lazy-load non-active videos with
`preload="none"`, add `will-change-[opacity]` for smoother crossfade

## Review Focus

- `@property` CSS registered custom property — verify browser support is
acceptable for the website target audience
- `!important` on the webkit media controls rule — necessary to override
UA styles on iOS Safari

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11586-fix-website-polish-prefetch-Safari-video-controls-border-spin-perf-34c6d73d36508108a0e8f67df9b32a88)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 07:44:02 +00:00
Alexander Brown
8e3041aa2f ci: add e2e-status gate job for required checks (#11587)
## Summary

Follow-up to #11568. Fixes Playwright required checks hanging as
"Waiting for status to be reported" on PRs with no e2e-relevant file
changes.

## Problem

PR #11568 added a `changes` filter to skip E2E when only
docs/apps/storybook files are touched. The E2E workflow skips correctly,
but branch rulesets require the 11 matrix-expanded check names (e.g.
`playwright-tests-chromium-sharded (1, 8)`). When a matrix job is
skipped via dependency, GitHub only reports the parent job name — the
individual matrix entries are never reported, so required checks hang
forever.

## Fix

Add a single `e2e-status` gate job that:
- Uses `if: always()` so it always runs regardless of skipped
dependencies
- Passes when E2E was intentionally skipped (no relevant changes)
- Passes when all matrix jobs succeeded
- Fails when any matrix job failed

**After merging**, the ProtectMain and Core release branches rulesets
should be updated to require `e2e-status` instead of the 11 individual
matrix check names.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11587-ci-add-e2e-status-gate-job-for-required-checks-34c6d73d365081b59c01e8d61d0b808d)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-04-24 05:43:45 +00:00
Alexander Brown
0b2e605dee content: website copy and pricing updates (#11567)
## Summary

Update website copy: fix branding ("Comfy" → "ComfyUI"), correct pricing
runtime, remove "coming soon" seat features, and shorten use-case label.

## Changes

- **What**: Copy corrections in `translations.ts` (branding, runtime "60
min" → "30 min" for Standard plan, remove placeholder seat features for
Creator/Pro plans); trim feature arrays in `PriceSection.vue`

## Review Focus

Verify zh-CN translations still make sense after the English copy
changes (runtime string not updated in zh-CN).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11567-content-website-copy-and-pricing-updates-34b6d73d365081c29af8ee1469b08358)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-04-24 04:25:15 +00:00
Christian Byrne
d038193d95 fix: add assetsPrefix to avoid /_astro collision with workflows site (#11584)
## Problem

The website and workflows sites both emit build assets under `/_astro/`.
The comfy-router sends all `/_astro/*` requests to the workflows Vercel
project, so when a page from the website references
`/_astro/SiteNav.xxx.js`, it 404s because that file only exists on the
website's Vercel deployment.

## Fix

Add `build.assetsPrefix: '/_website'` to the Astro config. This makes
Astro emit asset references as `/_website/SiteNav.xxx.js` instead of
`/_astro/SiteNav.xxx.js`.

The comfy-router already routes `/_website/*` to the website origin, so
these assets will resolve correctly.

## Why this matters

**This is blocking the comfy.org cutover from Framer → new site.**
Without it, the homepage loads but all CSS/JS 404s and nothing renders.

## Change

One line in `apps/website/astro.config.ts`:
```ts
build: {
  assetsPrefix: '/_website'
}
```

## Verification

After merge + Vercel deploy:
- Homepage HTML should reference `/_website/*.js` and `/_website/*.css`
instead of `/_astro/*`
- `/workflows` page should still reference `/_astro/*` (unaffected)
- Both sites render correctly through the proxy

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11584-fix-add-assetsPrefix-to-avoid-_astro-collision-with-workflows-site-34c6d73d36508101a932caa2905f0a2b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-23 21:05:21 -07:00
Benjamin Lu
04e430e006 fix: dedupe keybinding modifier display (#11570)
## Summary

Fix keybinding display so pressing a modifier key by itself shows that
modifier once instead of duplicated text like `Shift + Shift`.

## Changes

- **What**: Centralizes modifier key labels in `KeyComboImpl` and omits
the duplicated primary key when the pressed key is itself a modifier.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

The keybinding model still includes active modifiers for normal
shortcuts like `Ctrl + Shift + k`, while modifier-only input now renders
as a single key. Regression coverage includes single modifier presses,
combined held modifiers, and a normal non-modifier shortcut.

Checks run: `pnpm exec vitest run
src/platform/keybindings/keyCombo.test.ts`; `pnpm lint:unstaged`; `pnpm
exec oxfmt --check src/platform/keybindings/keyCombo.ts
src/platform/keybindings/keyCombo.test.ts`; `pnpm exec vitest run
src/platform/keybindings/keyCombo.test.ts
src/platform/keybindings/keybindingStore.test.ts
src/platform/keybindings/keybindingService.escape.test.ts
src/platform/keybindings/keybindingService.canvas.test.ts`; `pnpm
typecheck`; pre-commit lint-staged checks; pre-push `pnpm knip --cache`.

Linear: FE-240

## Screenshots (if applicable)

Not applicable; covered by keybinding sequence unit tests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11570-fix-dedupe-keybinding-modifier-display-34b6d73d365081968a88da4465c151de)
by [Unito](https://www.unito.io)
2026-04-24 03:41:42 +00:00
213 changed files with 19846 additions and 1340 deletions

View File

@@ -46,3 +46,9 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
# Ashby (apps/website careers page build).
# Server-only; read inside the Astro build context. Do NOT prefix with PUBLIC_.
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
# WEBSITE_ASHBY_API_KEY=
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org

147
.github/workflows/ci-deploy-preview.yaml vendored Normal file
View File

@@ -0,0 +1,147 @@
# Description: Builds ComfyUI frontend and deploys previews to Cloudflare Pages
name: 'CI: Deploy Preview'
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Post starting comment for non-forked PRs
comment-on-pr-start:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Post starting comment
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
./scripts/cicd/pr-preview-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"starting"
# Build frontend for all PRs and pushes
build:
runs-on: ubuntu-latest
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build frontend
env:
FRONTEND_COMMIT_HASH: ${{ github.sha }}
CI_BRANCH: ${{ github.head_ref || github.ref_name }}
CI_PR_NUMBER: ${{ github.event.pull_request.number || '' }}
CI_PR_AUTHOR: ${{ github.event.pull_request.user.login || '' }}
CI_RUN_ID: ${{ github.run_id }}
CI_JOB_ID: ${{ github.job }}
USE_PROD_CONFIG: 'true'
run: pnpm build
- name: Set job status
id: job-status
if: always()
run: |
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
- name: Get workflow URL
id: workflow-url
if: always()
run: |
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
- name: Upload build artifact
if: success() && github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v6
with:
name: dist
path: dist/
retention-days: 7
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [comment-on-pr-start, build]
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Download build artifact
if: needs.build.outputs.conclusion == 'success'
uses: actions/download-artifact@v7
with:
name: dist
path: dist
- name: Make deployment script executable
run: chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
- name: Deploy preview and comment on PR
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GITHUB_TOKEN: ${{ github.token }}
WORKFLOW_CONCLUSION: ${{ needs.build.outputs.conclusion }}
WORKFLOW_URL: ${{ needs.build.outputs.workflow-url }}
BRANCH_NAME: ${{ github.head_ref }}
run: |
./scripts/cicd/pr-preview-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"completed"
# Deploy to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build frontend
env:
FRONTEND_COMMIT_HASH: ${{ github.sha }}
CI_BRANCH: ${{ github.ref_name }}
CI_RUN_ID: ${{ github.run_id }}
CI_JOB_ID: ${{ github.job }}
run: pnpm build
- name: Deploy to Cloudflare Pages (production)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
pnpm dlx wrangler@^4.0.0 pages deploy dist \
--project-name=comfy-ui \
--branch=main

View File

@@ -223,6 +223,24 @@ jobs:
path: ./playwright-report/
retention-days: 30
# Gate job — single required check that passes whether the matrix ran or was
# skipped. Branch rulesets require this instead of the individual matrix-
# expanded check names so PRs with no e2e-relevant changes aren't stuck.
e2e-status:
if: ${{ always() }}
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should_run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
echo "E2E passed"
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)

View File

@@ -8,7 +8,7 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
cancel-in-progress: true
cancel-in-progress: false
jobs:
update-screenshots:

3
.gitignore vendored
View File

@@ -99,4 +99,5 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
.amp
.claude/scheduled_tasks.lock

View File

@@ -2,6 +2,7 @@ dist/
.astro/
test-results/
playwright-report/
results.json
# Platform-specific Playwright snapshots (CI runs Linux)
*-win32.png

123
apps/website/README.md Normal file
View File

@@ -0,0 +1,123 @@
# @comfyorg/website
Marketing/brand website built with Astro + Vue.
## Ashby careers integration
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
API at build time. Data flow:
1. `src/pages/careers.astro` awaits `fetchRolesForBuild()` during the
Astro build.
2. `src/utils/ashby.ts` calls
`GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=false`,
validates the envelope and each posting with Zod
(`src/utils/ashby.schema.ts`), and maps to the domain type in
`src/data/roles.ts`.
3. On any failure (network, HTTP 4xx/5xx, envelope schema drift),
the fetcher falls back to the committed JSON snapshot at
`src/data/ashby-roles.snapshot.json`.
4. `src/utils/ashby.ci.ts` emits GitHub Actions annotations and a
`$GITHUB_STEP_SUMMARY` block so stale fetches are visible on green
builds.
### Required environment variables
Both are build-time only. Never prefix with `PUBLIC_` (Astro would
inline that into the client bundle).
| Name | Purpose | Default (when unset) |
| ------------------------------ | --------------------------- | --------------------------------- |
| `WEBSITE_ASHBY_API_KEY` | Ashby API key (Basic auth) | Build uses the committed snapshot |
| `WEBSITE_ASHBY_JOB_BOARD_NAME` | Ashby public job board slug | Build uses the committed snapshot |
### CI wiring (manual step — required)
This repo's `.github/workflows/*.yaml` changes cannot be pushed by a
GitHub App. A maintainer must apply the following edits **once**:
**`.github/workflows/ci-website-build.yaml`** — pass the env into the
build step and run the unit tests before it:
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run website unit tests
run: pnpm --filter @comfyorg/website test:unit
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
run: pnpm --filter @comfyorg/website build
- name: Verify API key is not leaked into build output
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
run: |
set +x
if [ -z "${WEBSITE_ASHBY_API_KEY:-}" ]; then
echo "Secret not available in this run; skipping leak check."
exit 0
fi
# grep -rlF prints only file paths (never match content).
MATCHES=$(grep -rlF --exclude-dir=node_modules --null \
-e "$WEBSITE_ASHBY_API_KEY" apps/website/dist/ 2>/dev/null \
| tr '\0' '\n' || true)
if [ -n "$MATCHES" ]; then
echo "::error title=Ashby API key leaked into build output::$MATCHES"
exit 1
fi
```
**`.github/workflows/ci-vercel-website-preview.yaml`** — add the
two env vars to the top-level `env:` block so `vercel build` (both
`deploy-preview` and `deploy-production` jobs) sees them:
```yaml
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
```
The secret must also be added to the Vercel project environment
(`vercel env add WEBSITE_ASHBY_API_KEY …` or via the Vercel UI) so
that `vercel build` in the preview job has access to it.
Fork PRs do not exercise this path: `ci-vercel-website-preview.yaml`
receives an empty `VERCEL_TOKEN` for forks and fails at `vercel pull`
before the build runs. Fork-safe PR interactions (the preview-URL
comment) are handled by `pr-vercel-website-preview.yaml`.
### Refreshing the snapshot
When a maintainer wants to update the committed snapshot (e.g. after
onboarding/offboarding roles):
```bash
WEBSITE_ASHBY_API_KEY=WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org \
pnpm --filter @comfyorg/website ashby:refresh-snapshot
git commit apps/website/src/data/ashby-roles.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## Scripts
- `pnpm dev` — Astro dev server
- `pnpm build` — production build to `dist/`
- `pnpm typecheck``astro check`
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot

View File

@@ -6,6 +6,16 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
redirects: {
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
},
build: {
assets: '_website'
},
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [vue(), sitemap()],
vite: {

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Careers page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/careers')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers — Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Roles', level: 2 })
).toBeVisible()
})
test('renders at least one role from the snapshot', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
await expect(roles.first()).toBeVisible()
expect(await roles.count()).toBeGreaterThan(0)
})
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
}
})
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 — Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()
await expect(page.getByTestId('careers-role-link').first()).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -9,10 +9,13 @@
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots"
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
@@ -23,7 +26,8 @@
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"vue": "catalog:"
"vue": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
@@ -32,7 +36,9 @@
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
@@ -89,6 +95,22 @@
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [

View File

@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<g id="title404" fill="currentColor" transform="matrix(0.9346 -0.02737 -0.00901 1.0422 -597.31 -135.88)">
<path d="M1436.44 522.556V439.749L1462.05 454.536C1478.8 464.21 1489.92 479.348 1489.92 505.313V518.746C1489.92 544.611 1478.72 546.966 1462.05 537.342L1436.44 522.556ZM1442.77 519.899L1461.87 530.926C1477.15 539.748 1483.32 535.087 1483.32 515.137V501.303C1483.32 481.253 1477.15 469.573 1461.87 460.751L1442.77 449.724V519.899Z"/>
<path d="M1369.77 484.067V401.261L1376.02 404.87L1416.65 498.303V428.328L1422.73 431.837V514.643L1416.48 511.034L1375.85 417.601V487.576L1369.77 484.067Z"/>
<path d="M1330.66 462.491C1311.56 451.464 1304.62 438.231 1304.62 417.68V363.645L1311.04 367.354V421.69C1311.04 440.737 1318.34 448.857 1330.66 455.975C1342.99 463.093 1350.2 463.343 1350.2 444.296V389.96L1356.62 393.67V447.704C1356.62 468.757 1349.24 473.218 1330.66 462.491Z"/>
<path d="M1265.37 424.794C1245.75 413.466 1236.11 392.263 1236.11 372.614V358.278C1236.11 338.83 1245.75 328.654 1265.37 339.983C1285.08 351.361 1294.72 372.664 1294.72 392.113V406.448C1294.72 426.097 1285.08 436.173 1265.37 424.794ZM1242.71 376.724C1242.71 394.068 1250.53 409.506 1265.37 418.077C1280.3 426.699 1288.12 420.283 1288.12 402.94V388.002C1288.12 370.96 1280.3 355.321 1265.37 346.699C1250.53 338.128 1242.71 344.745 1242.71 361.787V376.724Z"/>
<path d="M1187.24 378.685V295.879L1231.78 321.593V328.009L1193.58 305.954V341.643L1229.79 362.545V368.961L1193.58 348.059V382.344L1187.24 378.685Z"/>
<path d="M1129.17 345.153V268.763L1106.33 255.58V249.164L1158.34 279.189V285.605L1135.5 272.422V348.812L1129.17 345.153Z"/>
<path d="M1076.63 315.825C1057.01 304.497 1047.37 283.294 1047.37 263.645V249.309C1047.37 229.861 1057.01 219.685 1076.63 231.014C1096.34 242.392 1105.98 263.695 1105.98 283.144V297.479C1105.98 317.128 1096.34 327.204 1076.63 315.825ZM1053.97 267.755C1053.97 285.099 1061.79 300.537 1076.63 309.108C1091.56 317.73 1099.38 311.314 1099.38 293.971V279.033C1099.38 261.991 1091.56 246.352 1076.63 237.73C1061.79 229.159 1053.97 235.776 1053.97 252.818V267.755Z"/>
<path d="M983.922 261.297V178.49L990.173 182.099L1030.8 275.532V205.558L1036.88 209.066V291.873L1030.63 288.264L989.999 194.831V264.805L983.922 261.297Z"/>
<path d="M905.279 215.892V133.086L949.557 158.65V165.066L911.617 143.161V174.94L947.994 195.943V202.359L911.617 181.356V213.135L949.557 235.04V241.456L905.279 215.892Z"/>
<path d="M864.552 193.381C846.059 182.704 836.422 161.702 836.422 141.852V127.517C836.422 107.767 845.972 97.8425 865.507 109.121C883.131 119.296 892.855 137.642 893.723 154.083L893.81 158.243L887.038 154.333V149.822C886.343 135.687 878.356 123.156 865.68 115.837C850.313 106.965 842.934 113.832 842.934 130.875V145.912C842.934 163.356 850.4 178.594 865.073 187.065C878.096 194.584 886.864 191.225 887.125 175.436V170.123L860.732 154.885V148.669L893.81 167.767V209.27L889.208 206.614L888.601 191.426C884.52 198.193 876.706 200.398 864.552 193.381Z"/>
<path d="M777.479 142.107L801.615 73.2351L809.515 77.7965L833.738 174.588L826.966 170.678L821.93 150.027L789.286 131.18L784.251 146.017L777.479 142.107ZM791.11 125.816L820.107 142.558L805.869 84.2124L805.522 83.0094L805.261 83.8616L791.11 125.816Z"/>
<path d="M727.161 113.056V30.2494L756.853 47.3921C772.047 56.164 779.513 69.2967 779.513 86.9407V88.1437C779.513 105.687 772.047 110.6 756.853 101.828L733.499 88.3442V116.715L727.161 113.056ZM733.499 82.0285L756.419 95.2615C767.792 101.828 772.915 98.67 772.915 84.7352V82.63C772.915 68.7955 767.792 60.0236 756.419 53.4572L733.499 40.2242V82.0285Z"/>
<path d="M582.85 5.5V67.98L592.376 62.48V72.38L582.85 77.88V96.36L572.942 102.08V83.6L532.075 107.195V96.855L573.133 11.11L582.85 5.5ZM541.601 91.795L572.942 73.7V27.61L541.601 91.795Z"/>
<path d="M495.102 148.121C475.096 159.671 464.903 150.816 464.903 126.286V111.546C464.903 87.2364 475.192 66.5564 495.102 55.0614C514.631 43.7864 524.824 52.6414 524.824 76.9514V91.6914C524.824 116.221 514.631 136.846 495.102 148.121ZM475.001 121.556C475.001 127.936 475.763 132.666 477.287 136.076L509.296 63.2564C506.057 60.8364 501.294 61.3864 495.102 64.9614C481.765 72.6614 475.001 86.0264 475.001 104.726V121.556ZM480.812 140.311C484.146 142.456 488.91 141.796 495.102 138.221C508.057 130.741 514.726 117.431 514.726 98.5114V81.6814C514.726 75.7414 514.059 71.1764 512.725 67.8764L480.812 140.311Z"/>
<path d="M447.443 83.6773V146.157L456.969 140.657V150.557L447.443 156.057V174.537L437.535 180.257V161.777L396.668 185.372V175.032L437.726 89.2873L447.443 83.6773ZM406.194 169.972L437.535 151.877V105.787L406.194 169.972Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,33 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchRolesForBuild } from '../src/utils/ashby'
const snapshotPath = fileURLToPath(
new URL('../src/data/ashby-roles.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchRolesForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
writeFileSync(
tempPath,
JSON.stringify(outcome.snapshot, null, 2) + '\n',
'utf8'
)
renameSync(tempPath, snapshotPath)
const totalRoles = outcome.snapshot.departments.reduce(
(n, d) => n + d.roles.length,
0
)
process.stdout.write(
`Wrote snapshot with ${totalRoles} role(s) to ${snapshotPath}\n`
)

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const investors = [
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
{ name: 'ABSTRACT', icon: '/icons/investors/abstract.svg' },
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
]
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.story.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
>
{{ t('about.story.headingBefore', locale)
}}<span class="text-primary-comfy-yellow">{{
t('about.story.headingHighlight', locale)
}}</span
>{{ t('about.story.headingAfter', locale) }}
</h2>
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
{{ t('about.story.body', locale) }}
</p>
</div>
<!-- Investor card -->
<div
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
>
<div class="inline-flex items-center">
<!-- OUR badge (shorter) -->
<div class="relative z-10 flex h-9 items-center">
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
>
OUR
</span>
</div>
<!-- Union connector (overlaps both badges to eliminate seams) -->
<img
src="/icons/node-union-2size-reverse.svg"
alt=""
class="relative z-20 -mx-px h-12 w-auto"
/>
<!-- INVESTORS badge (taller) -->
<div class="relative z-10 flex h-12 items-center">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
>
INVESTORS
</span>
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
</div>
</div>
<p
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
>
{{ t('about.story.investorsBody', locale) }}
</p>
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
<div
v-for="investor in investors"
:key="investor.name"
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
>
<img
:src="investor.icon"
:alt="investor.name"
class="max-h-8 w-auto"
/>
</div>
</div>
</div>
<!-- Quote card -->
<div
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
>
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
{{ t('about.quote.text', locale) }}
</p>
<p
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
>
{{ t('about.quote.attribution', locale) }}
</p>
</div>
</section>
</template>

View File

@@ -1,121 +1,42 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const { locale = 'en', departments = [] } = defineProps<{
locale?: Locale
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
interface Role {
title: string
department: string
location: string
id: string
}
interface Department {
name: string
key: string
roles: Role[]
}
const departments: Department[] = [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
id: 'abc787b9-ad85-421c-8218-debd23bea096'
},
{
title: 'Software Engineer',
department: 'Engineering',
location: 'San Francisco',
id: '99dc26c7-51ca-43cd-a1ba-7d475a0f4a40'
},
{
title: 'Product Manager',
department: 'Engineering',
location: 'London, UK',
id: '12dbc26e-9f6d-49bf-83c6-130f7566d03c'
},
{
title: 'Tech Lead Manager, Frontend',
department: 'Engineering',
location: 'San Francisco',
id: 'a0665088-3314-457a-aa7b-12ca5c3eb261'
}
]
},
{
name: 'DESIGN',
key: 'design',
roles: [
{
title: 'Creative Director',
department: 'Design',
location: 'San Francisco',
id: '49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f'
},
{
title: 'Graphic Designer',
department: 'Design',
location: 'London, UK',
id: '19ba10aa-4961-45e8-8473-66a8a7a8079d'
},
{
title: 'Freelance Motion Designer',
department: 'Design',
location: 'Remote',
id: 'a7ccc2b4-4d9d-4e04-b39c-28a711995b5b'
}
]
},
{
name: 'MARKETING',
key: 'marketing',
roles: [
{
title: 'Lifecycle Growth Marketer',
department: 'Marketing',
location: 'San Francisco',
id: 'be74d210-3b50-408c-9f61-8fee8833ce64'
},
{
title: 'Graphic Designer',
department: 'Marketing',
location: 'London, UK',
id: '28dea965-662b-4786-b024-c9a1b6bc1f23'
}
]
}
]
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...departments.map((d) => ({ label: d.name, value: d.key }))
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? departments
: departments.filter((d) => d.key === activeCategory.value)
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
</script>
<template>
<section class="px-6 py-20 md:px-20 md:py-32">
<section class="px-6 py-20 md:px-20 md:py-32" data-testid="careers-roles">
<div class="mx-auto max-w-6xl">
<div class="flex flex-col gap-12 md:flex-row md:gap-20">
<!-- Left sidebar -->
<div class="shrink-0 md:w-48">
<div
class="bg-primary-comfy-ink sticky top-20 z-10 py-4 md:top-28 md:py-0"
@@ -126,6 +47,7 @@ const filteredDepartments = computed(() =>
{{ t('careers.roles.heading', locale) }}
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
class="mt-4"
@@ -133,8 +55,15 @@ const filteredDepartments = computed(() =>
</div>
</div>
<!-- Role listings -->
<div class="min-w-0 flex-1">
<p
v-if="!hasRoles"
class="text-primary-warm-gray text-base md:text-lg"
data-testid="careers-roles-empty"
>
{{ t('careers.roles.empty', locale) }}
</p>
<div
v-for="dept in filteredDepartments"
:key="dept.key"
@@ -147,10 +76,11 @@ const filteredDepartments = computed(() =>
<a
v-for="role in dept.roles"
:key="role.id"
:href="`https://jobs.ashbyhq.com/comfy-org/${role.id}`"
:href="role.applyUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
data-testid="careers-role-link"
>
<div class="min-w-0">
<span

View File

@@ -5,8 +5,6 @@ const { playing = false, size = 'md' } = defineProps<{
playing?: boolean
size?: 'sm' | 'md'
}>()
defineEmits<{ toggle: [] }>()
</script>
<template>
@@ -18,7 +16,6 @@ defineEmits<{ toggle: [] }>()
size === 'sm' ? 'size-8 lg:size-10' : 'size-12'
)
"
@click="$emit('toggle')"
>
<svg
v-if="playing"

View File

@@ -201,6 +201,7 @@ function toggleFullscreen() {
playsinline
:autoplay
muted
@click="playing = !playing"
>
<track
v-for="track in tracks"
@@ -221,13 +222,14 @@ function toggleFullscreen() {
playing && !hovering && 'pointer-events-none opacity-0'
)
"
@click="playing = !playing"
>
<PlayPauseButton
:playing
:aria-label="
playing ? t('player.pause', locale) : t('player.play', locale)
"
@toggle="playing = !playing"
@click.stop="playing = !playing"
/>
</div>
@@ -248,7 +250,7 @@ function toggleFullscreen() {
:aria-label="
playing ? t('player.pause', locale) : t('player.play', locale)
"
@toggle="playing = !playing"
@click="playing = !playing"
/>
<!-- Progress scrubber -->

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
const { src, clipId, objectPosition } = defineProps<{
src: string
clipId: string
objectPosition?: 'top' | 'bottom' | 'center'
}>()
const positionClass = {
top: 'object-top',
bottom: 'object-bottom',
center: 'object-center'
} as const
function isVideo(url: string): boolean {
return url.endsWith('.webm')
}
</script>
<template>
<div
class="relative size-full overflow-hidden"
:style="`clip-path: url(#${clipId})`"
>
<Transition name="crossfade">
<video
v-if="isVideo(src)"
:key="`video-${src}`"
:src
:poster="src.replace('.webm', '.webp')"
autoplay
muted
loop
playsinline
disablepictureinpicture
aria-hidden="true"
:class="
cn(
'absolute inset-0 size-full object-cover',
objectPosition && positionClass[objectPosition]
)
"
/>
<img
v-else
:key="`img-${src}`"
:src
alt=""
aria-hidden="true"
:class="
cn(
'absolute inset-0 size-full object-cover',
objectPosition && positionClass[objectPosition]
)
"
/>
</Transition>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
const { side } = defineProps<{
side: 'left' | 'right'
}>()
</script>
<template>
<div
class="pointer-events-none flex min-h-0 items-center overflow-clip lg:overflow-visible"
>
<div
:class="
cn(
'aspect-2/3 w-40 lg:aspect-auto lg:h-[72vh] lg:max-h-240 lg:w-[115%]',
side === 'left' ? 'ml-auto lg:-ml-[12%]' : 'mr-auto lg:-mr-[12%]'
)
"
>
<slot />
</div>
</div>
</template>

View File

@@ -25,8 +25,7 @@ const steps = [
},
{
number: '2',
title: t('getStarted.step2.title', locale),
description: t('getStarted.step2.description', locale)
title: t('getStarted.step2.title', locale)
},
{
number: '3',
@@ -74,6 +73,17 @@ const steps = [
>
{{ step.description }}
</p>
<p
v-else-if="step.number === '2'"
class="text-primary-comfy-canvas flex-1 text-sm"
>
{{ t('getStarted.step2.descriptionPrefix', locale)
}}<a
:href="externalLinks.workflows"
class="text-primary-comfy-yellow hover:underline"
>{{ t('getStarted.step2.descriptionLink', locale) }}</a
>{{ t('getStarted.step2.descriptionSuffix', locale) }}
</p>
<p v-if="step.links" class="flex-1 text-sm">
<template v-for="(link, i) in step.links" :key="link.href">
<a

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useTemplateRefsList } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { ref, useTemplateRef, watch } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -37,6 +37,12 @@ const badgeSegments = [
const activeIndex = ref(0)
const videoRefs = useTemplateRefsList<HTMLVideoElement>()
const sectionRef = useTemplateRef<HTMLElement>('sectionRef')
const isVisible = ref(false)
useIntersectionObserver(sectionRef, ([entry]) => {
isVisible.value = entry?.isIntersecting ?? false
})
watch(activeIndex, (current, previous) => {
videoRefs.value[previous]?.pause()
@@ -49,7 +55,7 @@ watch(activeIndex, (current, previous) => {
</script>
<template>
<section class="px-4 py-20 lg:px-20 lg:py-24">
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
<!-- Section header -->
<div class="flex flex-col items-center text-center">
<NodeBadge :segments="badgeSegments" segment-class="" />
@@ -66,23 +72,13 @@ watch(activeIndex, (current, previous) => {
<!-- Video area (desktop only) -->
<div class="hidden flex-1 lg:flex">
<div
class="rounded-5xl relative flex w-full items-center justify-center overflow-hidden p-0.5 [clip-path:inset(0_round_var(--radius-5xl))]"
:class="
cn(
'rounded-5xl relative flex w-full items-center justify-center overflow-hidden p-0.5',
isVisible && 'animate-border-spin'
)
"
>
<div
class="animate-border-spin absolute top-1/2 left-1/2 aspect-square min-h-full min-w-full -translate-1/2 scale-150"
style="
background: conic-gradient(
from 0deg,
color-mix(
in srgb,
var(--color-primary-comfy-yellow) 4%,
transparent
)
0%,
var(--color-primary-comfy-yellow) 100%
);
"
/>
<div
class="bg-primary-comfy-ink relative size-full overflow-hidden rounded-[calc(2.5rem-2px)]"
>
@@ -92,12 +88,13 @@ watch(activeIndex, (current, previous) => {
:key="feature.title"
:src="feature.video"
:autoplay="i === 0"
:preload="i === 0 ? 'metadata' : 'none'"
loop
muted
playsinline
:class="
cn(
'absolute inset-0 size-full object-cover transition-opacity duration-300',
'absolute inset-0 size-full object-cover transition-opacity duration-300 will-change-[opacity]',
activeIndex === i ? 'opacity-100' : 'opacity-0'
)
"
@@ -112,21 +109,24 @@ watch(activeIndex, (current, previous) => {
<!-- Video area (mobile, rendered before active item) -->
<div
v-if="activeIndex === i"
:class="
cn(
'aspect-video overflow-hidden rounded-4xl lg:hidden',
i !== 0 && 'mt-4'
)
"
:class="cn('aspect-video lg:hidden', i !== 0 && 'mt-4')"
>
<video
:src="feature.video"
autoplay
loop
muted
playsinline
class="size-full object-cover"
/>
<div
class="animate-border-spin size-full overflow-hidden rounded-4xl p-0.5"
>
<div
class="bg-primary-comfy-ink size-full overflow-hidden rounded-[calc(2rem-2px)]"
>
<video
:src="feature.video"
autoplay
loop
muted
playsinline
class="size-full object-cover"
/>
</div>
</div>
</div>
<!-- Connector (mobile) -->

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed, ref, useId } from 'vue'
import { whenever } from '@vueuse/core'
import { computed, ref, useId, watch } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -8,10 +9,19 @@ import { externalLinks } from '../../config/routes'
import { useParallax } from '../../composables/useParallax'
import { usePinScrub, VH_PER_ITEM } from '../../composables/usePinScrub'
import BrandButton from '../common/BrandButton.vue'
import BlobMedia from './BlobMedia.vue'
import BlobRail from './BlobRail.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const categories = [
interface Category {
label: string
leftSrc: string
rightSrc: string
rightObjectPosition?: 'top' | 'bottom' | 'center'
}
const categories: Category[] = [
{
label: t('useCase.vfx', locale),
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
@@ -40,10 +50,6 @@ const categories = [
}
]
function isVideo(src: string): boolean {
return src.endsWith('.webm')
}
const sectionRef = ref<HTMLElement>()
const contentRef = ref<HTMLElement>()
const navRef = ref<HTMLElement>()
@@ -53,6 +59,7 @@ const rightImgRef = ref<HTMLElement>()
const {
activeIndex: activeCategory,
isEnabled,
isPinned,
scrollToIndex
} = usePinScrub(
{ section: sectionRef, content: contentRef, nav: navRef },
@@ -69,34 +76,57 @@ const uid = useId()
const leftBlobId = `left-blob-${uid}`
const rightBlobId = `right-blob-${uid}`
function navButtons() {
return navRef.value?.querySelectorAll<HTMLButtonElement>(':scope > button')
}
whenever(isPinned, () => {
navButtons()?.[activeCategory.value]?.focus({ preventScroll: true })
})
watch(activeCategory, (index) => {
if (!isPinned.value) return
navButtons()?.[index]?.focus({ preventScroll: true })
})
function onNavKeydown(event: KeyboardEvent) {
const delta = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0
if (!delta) return
event.preventDefault()
const next = Math.min(
categories.length - 1,
Math.max(0, activeCategory.value + delta)
)
scrollToIndex(next)
const current = activeCategory.value
const next = current + delta
const buttons =
navRef.value?.querySelectorAll<HTMLButtonElement>(':scope > button')
buttons?.[next]?.focus({ preventScroll: true })
if (next < 0 || next >= categories.length) {
navButtons()?.[current]?.blur()
return
}
scrollToIndex(next)
navButtons()?.[next]?.focus({ preventScroll: true })
}
function travelRange(el: HTMLElement) {
if (window.matchMedia('(min-width: 1024px)').matches) return 150
const rail = el.parentElement?.parentElement
if (!rail) return 0
const pb = parseFloat(getComputedStyle(rail).paddingBottom)
return Math.max(0, (rail.clientHeight - pb - el.offsetHeight) / 2)
}
const pinScrubEnd = `+=${categories.length * VH_PER_ITEM}%`
useParallax([rightImgRef], {
trigger: sectionRef,
fromY: -150,
y: 150,
fromY: (el) => -travelRange(el),
y: (el) => travelRange(el),
start: 'top top',
end: pinScrubEnd
})
useParallax([leftImgRef], {
trigger: sectionRef,
fromY: 150,
y: -150,
fromY: (el) => travelRange(el),
y: (el) => -travelRange(el),
start: 'top top',
end: pinScrubEnd
})
@@ -107,12 +137,11 @@ useParallax([leftImgRef], {
ref="sectionRef"
:class="
cn(
'bg-primary-comfy-ink relative isolate px-8 py-20 lg:px-0 lg:py-24',
isEnabled && 'overflow-x-clip lg:h-[calc(100vh+60px)]'
'bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:py-24',
isEnabled && 'lg:h-[calc(100vh+60px)]'
)
"
>
<!-- Clip-path definitions for shaped images -->
<svg class="absolute size-0" width="0" height="0" aria-hidden="true">
<defs>
<clipPath :id="leftBlobId" clipPathUnits="objectBoundingBox">
@@ -129,63 +158,33 @@ useParallax([leftImgRef], {
</svg>
<div
class="relative mx-auto grid w-full grid-cols-1 grid-rows-[auto_minmax(0,1fr)] lg:h-full lg:grid-cols-[minmax(0,1fr)_minmax(24rem,42rem)_minmax(0,1fr)]"
class="relative mx-auto grid w-full grid-cols-[minmax(0,5rem)_minmax(18ch,1fr)_minmax(0,5rem)] grid-rows-[auto_minmax(0,1fr)_auto] lg:h-full lg:grid-cols-[minmax(0,1fr)_minmax(24rem,42rem)_minmax(0,1fr)] lg:gap-x-0"
>
<!-- Label row spanning all columns -->
<div
class="from-primary-comfy-ink to-primary-comfy-ink/10 relative z-20 col-span-full bg-linear-to-b py-4"
>
<!-- Label row -->
<div class="relative z-20 col-span-full flex justify-center py-4">
<p
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase lg:text-base"
class="text-primary-comfy-yellow from-primary-comfy-ink to-primary-comfy-ink/10 shrink grow-0 bg-linear-to-b text-center text-sm font-bold tracking-widest uppercase lg:text-base"
>
{{ t('useCase.label', locale) }}
</p>
</div>
<!-- Left image -->
<div
class="pointer-events-none relative hidden min-h-0 lg:flex lg:items-center lg:justify-start"
>
<div class="w-[115%] -translate-x-[12%]">
<div
ref="leftImgRef"
class="relative h-[72vh] max-h-240 w-full overflow-hidden will-change-transform"
:style="`clip-path: url(#${leftBlobId})`"
>
<Transition name="crossfade">
<video
v-if="isVideo(activeLeft)"
:key="`video-${activeLeft}`"
:src="activeLeft"
autoplay
muted
loop
playsinline
aria-hidden="true"
class="absolute inset-0 size-full object-cover"
/>
<img
v-else
:key="`img-${activeLeft}`"
:src="activeLeft"
alt=""
aria-hidden="true"
class="absolute inset-0 size-full object-cover"
/>
</Transition>
</div>
<!-- Left blob rail -->
<BlobRail side="left">
<div ref="leftImgRef" class="size-full will-change-transform">
<BlobMedia :src="activeLeft" :clip-id="leftBlobId" />
</div>
</div>
</BlobRail>
<!-- Center content -->
<div class="relative z-10 min-h-0 overflow-hidden">
<div
ref="contentRef"
class="flex flex-col items-center will-change-transform"
class="flex h-full flex-col items-center will-change-transform"
>
<nav
ref="navRef"
class="mt-16 flex w-full max-w-5/6 flex-col items-center justify-center gap-12 lg:mt-20 lg:max-w-none lg:gap-8"
class="mt-16 flex w-full max-w-5/6 flex-1 flex-col items-center justify-evenly gap-12 lg:mt-[clamp(0.5rem,3vh,5rem)] lg:max-w-none lg:gap-[clamp(0.25rem,1vh,2rem)]"
:aria-label="t('useCase.navLabel', locale)"
@keydown="onNavKeydown"
>
@@ -195,7 +194,7 @@ useParallax([leftImgRef], {
type="button"
:class="
cn(
'lg:text-4.5xl cursor-pointer text-center text-4xl font-light whitespace-pre-line transition-colors outline-none',
'cursor-pointer text-center text-2xl font-light whitespace-pre-line transition-colors outline-none lg:text-[clamp(1rem,5vh,3rem)]',
index === activeCategory
? 'text-primary-comfy-canvas'
: 'text-primary-comfy-canvas/30 hover:text-primary-comfy-canvas/50'
@@ -207,66 +206,29 @@ useParallax([leftImgRef], {
{{ category.label }}
</button>
</nav>
<p
class="text-primary-warm-gray mt-20 max-w-md text-center text-base"
>
{{ t('useCase.body', locale) }}
</p>
<BrandButton
:href="externalLinks.workflows"
variant="outline"
class="mt-8"
>
{{ t('useCase.cta', locale) }}
</BrandButton>
</div>
</div>
<!-- Right image -->
<div
class="pointer-events-none relative hidden min-h-0 lg:flex lg:items-center lg:justify-end"
>
<div class="w-[115%] translate-x-[12%]">
<div
ref="rightImgRef"
class="relative h-[72vh] max-h-240 w-full overflow-hidden will-change-transform"
:style="`clip-path: url(#${rightBlobId})`"
>
<Transition name="crossfade">
<video
v-if="isVideo(activeRight)"
:key="`video-${activeRight}`"
:src="activeRight"
autoplay
muted
loop
playsinline
aria-hidden="true"
class="absolute inset-0 size-full object-cover"
:style="
activeRightObjectPosition
? `object-position: ${activeRightObjectPosition}`
: undefined
"
/>
<img
v-else
:key="`img-${activeRight}`"
:src="activeRight"
alt=""
aria-hidden="true"
class="absolute inset-0 size-full object-cover"
:style="
activeRightObjectPosition
? `object-position: ${activeRightObjectPosition}`
: undefined
"
/>
</Transition>
</div>
<!-- Right blob rail -->
<BlobRail side="right">
<div ref="rightImgRef" class="size-full will-change-transform">
<BlobMedia
:src="activeRight"
:clip-id="rightBlobId"
:object-position="activeRightObjectPosition"
/>
</div>
</BlobRail>
<div
class="col-span-full mt-8 flex flex-col items-center gap-8 px-4 lg:mt-[clamp(0.25rem,1vh,2rem)] lg:gap-[clamp(0.25rem,1vh,2rem)]"
>
<p class="text-primary-warm-gray max-w-md text-center text-base">
{{ t('useCase.body', locale) }}
</p>
<BrandButton :href="externalLinks.workflows" variant="outline">
{{ t('useCase.cta', locale) }}
</BrandButton>
</div>
</div>
</section>

View File

@@ -77,10 +77,7 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.creator.cta',
ctaHref: subscribeUrl('creator'),
featureIntroKey: 'pricing.plan.creator.featureIntro',
features: [
{ text: 'pricing.plan.creator.feature1' },
{ text: 'pricing.plan.creator.feature2' }
],
features: [{ text: 'pricing.plan.creator.feature1' }],
isPopular: true
},
{
@@ -93,10 +90,7 @@ const plans: PricingPlan[] = [
ctaKey: 'pricing.plan.pro.cta',
ctaHref: subscribeUrl('pro'),
featureIntroKey: 'pricing.plan.pro.featureIntro',
features: [
{ text: 'pricing.plan.pro.feature1' },
{ text: 'pricing.plan.pro.feature2' }
]
features: [{ text: 'pricing.plan.pro.feature1' }]
},
{
id: 'enterprise',
@@ -104,14 +98,7 @@ const plans: PricingPlan[] = [
summaryKey: 'pricing.enterprise.description',
ctaKey: 'pricing.enterprise.cta',
ctaHref: getRoutes(locale).cloudEnterprise,
featureIntroKey: 'pricing.enterprise.featureIntro',
features: [
{ text: 'pricing.enterprise.feature1' },
{ text: 'pricing.enterprise.feature2' },
{ text: 'pricing.enterprise.feature3' },
{ text: 'pricing.enterprise.feature4' }
],
andMoreKey: 'pricing.enterprise.andMore',
features: [],
isEnterprise: true
}
]
@@ -229,7 +216,7 @@ const activePlanIndex = ref(0)
<div v-else class="px-6" />
<!-- Features -->
<div class="px-6 py-3">
<div v-if="plan.features.length" class="px-6 py-3">
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
{{
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : '&nbsp;'
@@ -356,7 +343,10 @@ const activePlanIndex = ref(0)
</div>
<!-- Features card -->
<div class="bg-transparency-white-t4 mt-2 rounded-3xl p-6">
<div
v-if="plan.features.length"
class="bg-transparency-white-t4 mt-2 rounded-3xl p-6"
>
<PricingPlanFeatureList
:features="plan.features"
:feature-intro-key="plan.featureIntroKey"

View File

@@ -10,7 +10,7 @@ const features = [
{
title: t('api.automation.feature1.title', locale),
description: t('api.automation.feature1.description', locale),
image: 'https://media.comfy.org/website/gallery/desert.webp',
image: 'https://media.comfy.org/website/api/infrastructure-nodes.webp',
description2: t('api.automation.feature1.description2', locale)
},
{
@@ -22,7 +22,7 @@ const features = [
{
title: t('api.automation.feature3.title', locale),
description: t('api.automation.feature3.description', locale),
image: 'https://media.comfy.org/website/pricing/free.webp'
image: 'https://media.comfy.org/website/api/precision-tools.webp'
}
]
</script>

View File

@@ -223,14 +223,14 @@ onUnmounted(() => {
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
<BrandButton
:href="externalLinks.cloud"
:href="externalLinks.apiKeys"
size="lg"
class="text-center lg:min-w-60 lg:p-4"
>
{{ t('api.hero.getApiKeys', locale) }}
</BrandButton>
<BrandButton
:href="externalLinks.docs"
:href="externalLinks.docsApi"
variant="outline"
size="lg"
class="text-center lg:min-w-60 lg:p-4"

View File

@@ -13,13 +13,13 @@ const steps = [
number: '01',
titleKey: 'api.steps.step1.title' as const,
descriptionKey: 'api.steps.step1.description' as const,
image: 'https://media.comfy.org/website/api/logo-purple.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
},
{
number: '02',
titleKey: 'api.steps.step2.title' as const,
descriptionKey: 'api.steps.step2.description' as const,
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
},
{
number: '03',
@@ -61,7 +61,7 @@ const steps = [
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<BrandButton
:href="externalLinks.cloud"
:href="externalLinks.apiKeys"
variant="solid"
size="lg"
class="w-full text-center lg:w-auto lg:min-w-48"
@@ -69,7 +69,7 @@ const steps = [
{{ t('api.hero.getApiKeys', locale) }}
</BrandButton>
<BrandButton
:href="externalLinks.docs"
:href="externalLinks.docsApi"
variant="outline"
size="lg"
class="w-full text-center lg:w-auto lg:min-w-48"

View File

@@ -10,12 +10,12 @@ const cards = [
{
titleKey: 'enterprise.byoKey.card1.title' as const,
descriptionKey: 'enterprise.byoKey.card1.description' as const,
image: 'https://media.comfy.org/website/api/logo-purple.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_1.webp'
},
{
titleKey: 'enterprise.byoKey.card2.title' as const,
descriptionKey: 'enterprise.byoKey.card2.description' as const,
image: 'https://media.comfy.org/website/api/logo-yellow.webp'
image: 'https://media.comfy.org/website/enterprise/enterprise_node_2.webp'
}
]
</script>

View File

@@ -16,9 +16,11 @@ const midRightRef = ref<HTMLElement>()
const bottomLeftRef = ref<HTMLElement>()
const bottomRightRef = ref<HTMLElement>()
useParallax([topLeftRef, topRightRef], { trigger: sectionRef, y: 200 })
useParallax([midLeftRef, midRightRef], { trigger: sectionRef, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { trigger: sectionRef, y: 400 })
const parallaxOpts = { trigger: sectionRef, mediaQuery: '(min-width: 1024px)' }
useParallax([topLeftRef, topRightRef], { ...parallaxOpts, y: 200 })
useParallax([midLeftRef, midRightRef], { ...parallaxOpts, y: 300 })
useParallax([bottomLeftRef, bottomRightRef], { ...parallaxOpts, y: 400 })
</script>
<template>

View File

@@ -44,162 +44,303 @@ onMounted(() => {
<svg
ref="svgRef"
class="block size-full"
viewBox="600 -50 1000 1100"
viewBox="0 0 1600 1046"
fill="none"
aria-hidden="true"
>
<!-- Ripple rings -->
<path
class="ripple-path"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-1"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-2"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="ripple-path ripple-delay-3"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<g clip-path="url(#enterpriseHeroClip)">
<rect width="1600" height="1046" fill="#211927" />
<rect
width="800"
height="800"
transform="translate(712 112)"
fill="#211927"
/>
<!-- Exploding block cluster -->
<g stroke="#4D3762" stroke-width="2">
<!-- Ripple rings -->
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
fill="#37303F"
class="ripple-path"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
fill="#251D2B"
class="ripple-path delay-1"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
fill="#37303F"
class="ripple-path delay-2"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
fill="#251D2B"
class="ripple-path delay-3"
d="M862.278 684.584L1064.22 801.244C1091.06 816.752 1134.58 816.764 1161.41 801.27L1363.29 684.716C1380.29 674.902 1395.18 648.204 1395.17 628.577L1395.11 395.363C1395.1 364.36 1373.34 326.656 1346.49 311.148L1144.55 194.488C1127.56 184.67 1097 184.223 1080 194.037L878.12 310.591C851.283 326.085 829.535 363.778 829.543 394.78L829.604 627.993C829.61 647.659 845.248 674.747 862.278 684.584Z"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#F2FF59"
/>
<path
class="block-piece"
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.63 971.884 332.359 975.826 330.082Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.91 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
fill="#251D2B"
/>
<path
class="block-piece"
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
fill="#37303F"
/>
<path
class="block-piece"
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
fill="#37303F"
<!-- Exploding block cluster -->
<g class="block-cluster">
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.58 681.434L1098.6 627.449C1098.6 620.273 1093.57 611.548 1087.35 607.961L1040.62 580.981C1036.69 578.709 1029.61 578.814 1025.68 581.087L978.934 608.092C972.72 611.682 967.681 620.409 967.679 627.586L967.665 681.57C967.664 686.114 971.111 692.292 975.046 694.564L1021.78 721.544C1027.99 725.131 1038.06 725.128 1044.28 721.538L1091.02 694.534C1094.96 692.256 1098.58 685.986 1098.58 681.434Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.98 635.714L1205.97 581.73C1205.97 574.553 1211 565.828 1217.21 562.241L1263.94 535.261C1267.88 532.989 1274.95 533.095 1278.89 535.367L1325.63 562.372C1331.85 565.962 1336.89 574.69 1336.89 581.866L1336.9 635.851C1336.9 640.394 1333.46 646.573 1329.52 648.844L1282.79 675.824C1276.58 679.411 1266.5 679.408 1260.29 675.818L1213.54 648.814C1209.6 646.537 1205.98 640.266 1205.98 635.714Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.83 681.434L1125.81 627.45C1125.81 620.273 1130.84 611.548 1137.06 607.961L1183.79 580.981C1187.72 578.71 1194.8 578.815 1198.73 581.087L1245.48 608.092C1251.69 611.682 1256.73 620.41 1256.73 627.586L1256.75 681.571C1256.75 686.114 1253.3 692.293 1249.36 694.565L1202.63 721.545C1196.42 725.131 1186.35 725.128 1180.13 721.539L1133.39 694.534C1129.45 692.257 1125.83 685.987 1125.83 681.434Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.67 726.53L1045.66 672.545C1045.65 665.369 1050.69 656.644 1056.9 653.057L1103.63 626.077C1107.57 623.805 1114.64 623.911 1118.57 626.183L1165.32 653.188C1171.53 656.778 1176.57 665.506 1176.57 672.682L1176.59 726.667C1176.59 731.21 1173.14 737.388 1169.21 739.66L1122.48 766.64C1116.26 770.227 1106.19 770.224 1099.98 766.634L1053.23 739.63C1049.29 737.353 1045.67 731.082 1045.67 726.53Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1052.83 458.666L1099.57 485.671C1105.79 489.261 1115.86 489.263 1122.07 485.677L1168.8 458.697C1172.74 456.425 1176.19 450.245 1176.18 445.702L1176.17 391.717C1176.17 384.54 1171.13 375.812 1164.92 372.223L1118.17 345.218C1114.24 342.945 1107.16 342.842 1103.23 345.114L1056.5 372.094C1050.28 375.68 1045.25 384.405 1045.25 391.582L1045.27 445.566C1045.27 450.119 1048.89 456.389 1052.83 458.666Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1247.76 420.64L1201.02 393.635C1194.81 390.045 1184.73 390.043 1178.52 393.629L1131.79 420.609C1127.85 422.881 1124.41 429.061 1124.41 433.604L1124.42 487.589C1124.43 494.766 1129.46 503.493 1135.68 507.083L1182.42 534.088C1186.36 536.361 1193.43 536.464 1197.37 534.192L1244.1 507.212C1250.31 503.626 1255.34 494.901 1255.34 487.724L1255.33 433.74C1255.33 429.187 1251.71 422.917 1247.76 420.64Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M975.833 420.641L1022.58 393.636C1028.79 390.047 1038.87 390.044 1045.08 393.63L1091.81 420.61C1095.74 422.882 1099.19 429.062 1099.19 433.606L1099.17 487.59C1099.17 494.767 1094.13 503.495 1087.92 507.085L1041.17 534.089C1037.24 536.362 1030.17 536.465 1026.23 534.194L979.501 507.214C973.288 503.627 968.254 494.902 968.256 487.725L968.27 433.741C968.271 429.188 971.891 422.918 975.833 420.641Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.03 536.368L1018.04 482.384C1018.04 475.207 1013.01 466.482 1006.8 462.895L960.065 435.915C956.13 433.644 949.055 433.749 945.121 436.021L898.376 463.026C892.162 466.616 887.123 475.344 887.121 482.52L887.106 536.505C887.105 541.048 890.553 547.227 894.488 549.499L941.218 576.479C947.431 580.065 957.504 580.062 963.718 576.473L1010.46 549.468C1014.4 547.191 1018.02 540.921 1018.03 536.368Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.369L1175.18 482.384C1175.19 475.208 1170.15 466.483 1163.94 462.896L1117.21 435.916C1113.27 433.644 1106.2 433.749 1102.27 436.022L1055.52 463.027C1049.31 466.617 1044.27 475.344 1044.27 482.521L1044.25 536.506C1044.25 541.049 1047.7 547.227 1051.63 549.499L1098.36 576.479C1104.58 580.066 1114.65 580.063 1120.86 576.473L1167.61 549.469C1171.55 547.191 1175.17 540.921 1175.17 536.369Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.19 528.1C1098.19 520.924 1093.16 512.199 1086.95 508.612L1040.22 481.632C1036.28 479.36 1029.21 479.465 1025.27 481.738L978.528 508.743C972.314 512.333 967.275 521.061 967.273 528.237L967.259 582.222C967.257 586.765 970.705 592.943 974.64 595.215L1021.37 622.195C1027.58 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 536.367L1205.56 482.383C1205.56 475.206 1210.59 466.481 1216.8 462.894L1263.53 435.914C1267.47 433.643 1274.54 433.748 1278.48 436.02L1325.22 463.025C1331.44 466.615 1336.48 475.343 1336.48 482.519L1336.49 536.504C1336.49 541.047 1333.04 547.226 1329.11 549.497L1282.38 576.477C1276.17 580.064 1266.09 580.061 1259.88 576.471L1213.13 549.467C1209.19 547.19 1205.57 540.919 1205.57 536.367Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.4 528.101C1125.4 520.924 1130.43 512.199 1136.65 508.613L1183.38 481.633C1187.31 479.361 1194.39 479.466 1198.32 481.739L1245.07 508.743C1251.28 512.333 1256.32 521.061 1256.32 528.238L1256.34 582.222C1256.34 586.766 1252.89 592.944 1248.95 595.216L1202.22 622.196C1196.01 625.782 1185.94 625.78 1179.72 622.19L1132.98 595.185C1129.04 592.908 1125.42 586.638 1125.42 582.085Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.181L1045.25 573.197C1045.24 566.02 1050.28 557.295 1056.49 553.709L1103.22 526.729C1107.16 524.457 1114.23 524.562 1118.16 526.835L1164.91 553.839C1171.12 557.429 1176.16 566.157 1176.16 573.333L1176.18 627.318C1176.18 631.862 1172.73 638.04 1168.8 640.312L1122.07 667.292C1115.85 670.878 1105.78 670.876 1099.57 667.286L1052.82 640.281C1048.88 638.004 1045.26 631.734 1045.26 627.181Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 536.372L1175.19 482.387C1175.19 475.211 1170.16 466.485 1163.94 462.899L1117.21 435.919C1113.28 433.647 1106.2 433.752 1102.27 436.025L1055.52 463.03C1049.31 466.62 1044.27 475.347 1044.27 482.524L1044.25 536.508C1044.25 541.052 1047.7 547.23 1051.64 549.502L1098.37 576.482C1104.58 580.069 1114.65 580.066 1120.87 576.476L1167.61 549.472C1171.55 547.194 1175.17 540.924 1175.17 536.372Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.18 582.085L1098.2 528.1C1098.2 520.924 1093.16 512.198 1086.95 508.612L1040.22 481.632C1036.29 479.36 1029.21 479.465 1025.28 481.738L978.532 508.743C972.318 512.333 967.279 521.06 967.277 528.237L967.263 582.221C967.261 586.765 970.709 592.943 974.644 595.215L1021.37 622.195C1027.59 625.782 1037.66 625.779 1043.87 622.189L1090.62 595.185C1094.56 592.907 1098.18 586.637 1098.18 582.085Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.42 582.085L1125.41 528.1C1125.4 520.924 1130.44 512.198 1136.65 508.612L1183.38 481.632C1187.32 479.36 1194.39 479.465 1198.32 481.738L1245.07 508.743C1251.28 512.333 1256.32 521.06 1256.32 528.237L1256.34 582.221C1256.34 586.765 1252.89 592.943 1248.96 595.215L1202.23 622.195C1196.01 625.782 1185.94 625.779 1179.73 622.189L1132.98 595.184C1129.04 592.907 1125.42 586.637 1125.42 582.085Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 627.173L1045.25 573.188C1045.25 566.012 1050.28 557.286 1056.49 553.7L1103.22 526.72C1107.16 524.448 1114.23 524.553 1118.17 526.826L1164.91 553.831C1171.13 557.42 1176.17 566.148 1176.17 573.325L1176.18 627.309C1176.18 631.853 1172.74 638.031 1168.8 640.303L1122.07 667.283C1115.86 670.87 1105.79 670.867 1099.57 667.277L1052.83 640.272C1048.88 637.995 1045.26 631.725 1045.26 627.173Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1052.82 368.108L1099.57 395.112C1105.78 398.702 1115.85 398.705 1122.07 395.118L1168.8 368.138C1172.73 365.866 1176.18 359.686 1176.18 355.143L1176.16 301.158C1176.16 293.982 1171.12 285.254 1164.91 281.664L1118.16 254.659C1114.23 252.387 1107.15 252.283 1103.22 254.555L1056.49 281.535C1050.28 285.122 1045.24 293.847 1045.24 301.023L1045.26 355.008C1045.26 359.56 1048.88 365.83 1052.82 368.108Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1247.76 330.081L1201.02 303.077C1194.81 299.487 1184.73 299.484 1178.52 303.071L1131.79 330.051C1127.85 332.322 1124.41 338.502 1124.41 343.046L1124.42 397.031C1124.43 404.207 1129.46 412.935 1135.67 416.525L1182.42 443.529C1186.35 445.802 1193.43 445.906 1197.36 443.634L1244.09 416.654C1250.31 413.067 1255.34 404.342 1255.34 397.166L1255.32 343.181C1255.32 338.629 1251.7 332.358 1247.76 330.081Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M975.826 330.082L1022.57 303.078C1028.78 299.488 1038.86 299.485 1045.07 303.072L1091.8 330.052C1095.74 332.323 1099.18 338.503 1099.18 343.047L1099.17 397.032C1099.16 404.208 1094.13 412.936 1087.91 416.526L1041.17 443.53C1037.23 445.803 1030.16 445.907 1026.22 443.635L979.493 416.655C973.281 413.068 968.246 404.343 968.248 397.167L968.262 343.182C968.263 338.630 971.884 332.359 975.826 330.082Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1018.02 445.809L1018.04 391.825C1018.04 384.649 1013 375.923 1006.79 372.337L960.061 345.357C956.126 343.085 949.051 343.19 945.117 345.463L898.372 372.468C892.158 376.057 887.119 384.785 887.117 391.962L887.103 445.946C887.101 450.49 890.549 456.668 894.484 458.94L941.215 485.92C947.427 489.507 957.5 489.504 963.714 485.914L1010.46 458.909C1014.4 456.632 1018.02 450.362 1018.02 445.809Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1175.17 445.81L1175.18 391.826C1175.18 384.649 1170.15 375.924 1163.94 372.337L1117.21 345.357C1113.27 343.086 1106.2 343.191 1102.26 345.464L1055.52 372.468C1049.3 376.058 1044.26 384.786 1044.26 391.962L1044.25 445.947C1044.25 450.49 1047.69 456.669 1051.63 458.941L1098.36 485.921C1104.57 489.507 1114.64 489.505 1120.86 485.915L1167.6 458.91C1171.55 456.633 1175.17 450.363 1175.17 445.81Z"
fill="#F2FF59"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1098.17 491.528L1098.18 437.544C1098.19 430.367 1093.15 421.642 1086.94 418.056L1040.21 391.076C1036.27 388.804 1029.2 388.909 1025.27 391.182L978.52 418.186C972.306 421.776 967.267 430.504 967.265 437.681L967.251 491.665C967.25 496.209 970.697 502.387 974.632 504.659L1021.36 531.639C1027.58 535.225 1037.65 535.223 1043.86 531.633L1090.61 504.628C1094.55 502.351 1098.17 496.081 1098.17 491.528Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1205.57 445.809L1205.55 391.824C1205.55 384.648 1210.59 375.923 1216.8 372.336L1263.53 345.356C1267.46 343.084 1274.54 343.189 1278.47 345.462L1325.22 372.467C1331.43 376.057 1336.47 384.784 1336.47 391.961L1336.49 445.946C1336.49 450.489 1333.04 456.667 1329.11 458.939L1282.38 485.919C1276.16 489.506 1266.09 489.503 1259.88 485.913L1213.13 458.909C1209.19 456.631 1205.57 450.361 1205.57 445.809Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1125.41 491.529L1125.4 437.544C1125.4 430.368 1130.43 421.643 1136.64 418.056L1183.37 391.076C1187.31 388.804 1194.38 388.910 1198.32 391.182L1245.06 418.187C1251.28 421.777 1256.32 430.505 1256.32 437.681L1256.33 491.666C1256.33 496.209 1252.88 502.387 1248.95 504.659L1202.22 531.639C1196.01 535.226 1185.93 535.223 1179.72 531.633L1132.97 504.629C1129.03 502.352 1125.41 496.081 1125.41 491.529Z"
fill="#251D2B"
stroke="#4D3762"
stroke-width="2"
/>
<path
class="block-piece"
d="M1045.26 536.625L1045.24 482.64C1045.24 475.464 1050.27 466.738 1056.49 463.152L1103.22 436.172C1107.15 433.9 1114.23 434.005 1118.16 436.278L1164.91 463.283C1171.12 466.873 1176.16 475.6 1176.16 482.777L1176.17 536.761C1176.18 541.305 1172.73 547.483 1168.79 549.755L1122.06 576.735C1115.85 580.322 1105.78 580.319 1099.56 576.729L1052.82 549.725C1048.88 547.447 1045.26 541.177 1045.26 536.625Z"
fill="#37303F"
stroke="#4D3762"
stroke-width="2"
/>
</g>
<!-- Left-edge fade -->
<rect
width="422.621"
height="1125.11"
transform="matrix(-1 0 0 1 909.219 9.26587)"
fill="url(#enterpriseHeroFade)"
style="pointer-events: none"
/>
</g>
<!-- Left-edge fade -->
<rect
width="422.621"
height="1125.11"
transform="matrix(-1 0 0 1 909.219 9.26587)"
fill="url(#enterpriseHeroFade)"
/>
<defs>
<linearGradient
id="enterpriseHeroFade"
@@ -212,6 +353,9 @@ onMounted(() => {
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
<clipPath id="enterpriseHeroClip">
<rect width="1600" height="1046" fill="white" />
</clipPath>
</defs>
</svg>
</div>
@@ -255,13 +399,13 @@ onMounted(() => {
animation: ripple-effect 4s linear infinite;
}
.ripple-delay-1 {
.delay-1 {
animation-delay: -1s;
}
.ripple-delay-2 {
.delay-2 {
animation-delay: -2s;
}
.ripple-delay-3 {
.delay-3 {
animation-delay: -3s;
}
@@ -281,6 +425,11 @@ onMounted(() => {
}
}
.block-cluster {
transform-origin: center;
transform-box: fill-box;
}
.block-piece {
transform-origin: center;
transform-box: fill-box;

View File

@@ -164,11 +164,11 @@ onUnmounted(() => {
<template>
<section
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pb-[min(8vw,10rem)]"
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-visible lg:flex-row lg:items-center lg:pb-[min(8vw,10rem)]"
>
<!-- Illustration (stacks above on mobile, left on lg) -->
<div
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-[80px] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
class="aspect-550/800 w-4/5 max-w-md scale-150 self-center overflow-visible md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
ref="svgRef"
@@ -187,7 +187,7 @@ onUnmounted(() => {
rx="65.5036"
transform="matrix(-0.866025 -0.5 0 -1 620.969 1058.01)"
fill="#211927"
stroke="#7E7C78"
stroke="#4D3762"
stroke-width="3"
visibility="hidden"
/>
@@ -200,7 +200,7 @@ onUnmounted(() => {
rx="59.4123"
transform="matrix(-0.866025 -0.5 0 -1 675.746 878.068)"
fill="#211927"
stroke="#7E7C78"
stroke="#4D3762"
stroke-width="3"
/>
<rect
@@ -212,7 +212,7 @@ onUnmounted(() => {
rx="59.4123"
transform="matrix(-0.866025 -0.5 0 -1 675.746 878.068)"
fill="#211927"
stroke="#7E7C78"
stroke="#4D3762"
stroke-width="3"
/>
<rect
@@ -224,12 +224,12 @@ onUnmounted(() => {
rx="59.4123"
transform="matrix(-0.866025 -0.5 0 -1 675.746 878.068)"
fill="#211927"
stroke="#7E7C78"
stroke="#4D3762"
stroke-width="3"
/>
<!-- Hex nodes -->
<g stroke="#7E7C78" stroke-width="6">
<g stroke="#4D3762" stroke-width="3">
<path
data-hex="5"
d="M722.595 427.826L722.579 491.728C722.576 500.223 728.536 510.551 735.889 514.796L791.205 546.733C795.862 549.422 804.238 549.298 808.894 546.607L864.227 514.642C871.583 510.392 877.548 500.061 877.55 491.566L877.567 427.664C877.568 422.286 873.487 414.972 868.829 412.283L813.514 380.347C806.16 376.101 794.236 376.104 786.88 380.354L731.548 412.319C726.882 415.015 722.597 422.437 722.595 427.826Z"
@@ -283,11 +283,12 @@ onUnmounted(() => {
y="150"
width="250"
height="900"
fill="url(#localHeroFade)"
fill="url(#localHeroFadeLeft)"
/>
<defs>
<linearGradient
id="localHeroFade"
id="localHeroFadeLeft"
x1="550"
y1="600"
x2="300"

View File

@@ -12,10 +12,12 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<p
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
>
{{ t('download.cloud.prefix', locale) }}
<span class="whitespace-nowrap">
{{ t('download.cloud.prefix', locale) }}
</span>
<a
:href="externalLinks.cloud"
class="text-primary-comfy-yellow mx-1 font-bold tracking-widest uppercase hover:underline"
class="text-primary-comfy-yellow mx-1 font-bold tracking-widest whitespace-nowrap uppercase hover:underline"
>
{{ t('download.cloud.cta', locale) }}
</a>

View File

@@ -58,12 +58,11 @@ const {
:key="reason.titleKey"
class="border-primary-comfy-canvas/20 flex flex-col gap-4 border-b py-10 first:pt-0 lg:flex-row lg:gap-12"
>
<div class="shrink-0 lg:w-52">
<div class="shrink-0 lg:w-84">
<h3
class="text-primary-comfy-canvas text-2xl font-light whitespace-pre-line"
>
{{ t(reason.titleKey, locale) }}
</h3>
v-html="t(reason.titleKey, locale)"
/>
<slot name="reason-extra" :reason="reason" />
</div>
<p class="text-primary-comfy-canvas/70 flex-1 text-sm">

View File

@@ -3,16 +3,24 @@ import { onMounted, onUnmounted } from 'vue'
import { gsap } from '../scripts/gsapSetup'
import { prefersReducedMotion } from './useReducedMotion'
type ValueOrFn = number | ((el: HTMLElement, trigger: HTMLElement) => number)
function resolve(v: ValueOrFn, el: HTMLElement, trigger: HTMLElement): number {
return typeof v === 'function' ? v(el, trigger) : v
}
interface ParallaxOptions {
/** Starting vertical offset in pixels (default: 0) */
fromY?: number
/** Ending vertical offset in pixels (default: 200) */
y?: number
/** Starting vertical offset in pixels, or a function resolved at mount */
fromY?: ValueOrFn
/** Ending vertical offset in pixels, or a function resolved at mount */
y?: ValueOrFn
trigger?: Ref<HTMLElement | undefined>
/** ScrollTrigger start value (default: 'top bottom') */
start?: string
/** ScrollTrigger end value (default: 'bottom top') */
end?: string
/** Media query string — animation only runs when matched (responsive) */
mediaQuery?: string
}
export function useParallax(
@@ -20,27 +28,43 @@ export function useParallax(
options: ParallaxOptions = {}
) {
const { fromY = 0, y = 200 } = options
let ctx: gsap.Context | undefined
let ctx: gsap.Context | gsap.MatchMedia | undefined
onMounted(() => {
const trigger = options.trigger?.value
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length || prefersReducedMotion()) return
if (prefersReducedMotion()) return
const scrollTrigger = {
trigger: trigger ?? els[0],
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1
const triggerEl = options.trigger?.value
const createAnimations = () => {
const els = elements
.map((r) => r.value)
.filter((el): el is HTMLElement => !!el && el.offsetParent !== null)
if (!els.length) return
const trigger = triggerEl ?? els[0]
const scrollTrigger = {
trigger,
start: options.start ?? 'top bottom',
end: options.end ?? 'bottom top',
scrub: 1
}
els.forEach((el) => {
gsap.fromTo(
el,
{ y: resolve(fromY, el, trigger) },
{ y: resolve(y, el, trigger), ease: 'none', scrollTrigger }
)
})
}
ctx = gsap.context(() => {
els.forEach((el) => {
gsap.fromTo(el, { y: fromY }, { y, ease: 'none', scrollTrigger })
})
})
if (options.mediaQuery) {
const mm = gsap.matchMedia()
mm.add(options.mediaQuery, createAnimations)
ctx = mm
} else {
ctx = gsap.context(createAnimations)
}
})
onUnmounted(() => {

View File

@@ -43,6 +43,7 @@ function interpolateY(
export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
const activeIndex = ref(0)
const isEnabled = ref(false)
const isPinned = ref(false)
let ctx: gsap.Context | undefined
let scrollTriggerInstance: ScrollTrigger | undefined
@@ -81,6 +82,14 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
const sectionStyle = getComputedStyle(section)
contentH = content.scrollHeight
vpH = window.innerHeight - parseFloat(sectionStyle.paddingTop)
// Ensure the section is tall enough for its content so nothing clips
const naturalH = section.scrollHeight
const viewportH = window.innerHeight + 60
if (naturalH > viewportH) {
section.style.height = `${naturalH}px`
}
buttonCenters = Array.from(nav.querySelectorAll(':scope > *')).map(
(btn) => {
const btnRect = btn.getBoundingClientRect()
@@ -104,11 +113,8 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
end: `+=${options.itemCount * vhPerItem}%`,
pin: true,
scrub: true,
snap: {
snapTo: 1 / (options.itemCount - 1),
duration: { min: 0.2, max: 0.6 },
delay: 0.1,
ease: 'power1.inOut'
onToggle(self) {
isPinned.value = self.isActive
},
onRefresh: cacheLayout,
onUpdate(self) {
@@ -138,5 +144,5 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) {
ctx?.revert()
})
return { activeIndex, isEnabled, scrollToIndex }
return { activeIndex, isEnabled, isPinned, scrollToIndex }
}

View File

@@ -27,11 +27,14 @@ export function getRoutes(locale: Locale = 'en'): Routes {
}
export const externalLinks = {
cloud: 'https://cloud.comfy.org',
workflows: 'https://comfy.org/workflows',
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
github: 'https://github.com/Comfy-Org/ComfyUI',
cloud: 'https://cloud.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
github: 'https://github.com/Comfy-Org/ComfyUI',
platform: 'https://platform.comfy.org',
workflows: 'https://comfy.org/workflows',
youtube: 'https://www.youtube.com/@ComfyOrg'
} as const

View File

@@ -0,0 +1,169 @@
{
"fetchedAt": "2026-04-24T18:59:03.989Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "e915f2c78b17f93b",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
},
{
"id": "b9f9a23219be7cd4",
"title": "Design Engineer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
},
{
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
},
{
"id": "547b6ba622c800a5",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
},
{
"id": "7bb02634a24763bc",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
}
]
},
{
"name": "ENGINEERING",
"key": "engineering",
"roles": [
{
"id": "102d58e35a8a9817",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
},
{
"id": "d01d69fba7743905",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
},
{
"id": "f36f60cfd5bb5910",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
},
{
"id": "9d8ec4c65e20b19e",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
},
{
"id": "be94b193d1f4d482",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
},
{
"id": "ab48f5db6bd1783c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
},
{
"id": "c5dff4ee628bdcd1",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
},
{
"id": "4302a7aaa87e16e3",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
}
]
},
{
"name": "MARKETING",
"key": "marketing",
"roles": [
{
"id": "b5803a0d4785d406",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
},
{
"id": "130d7218d7895bdb",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
}
]
},
{
"name": "OPERATIONS",
"key": "operations",
"roles": [
{
"id": "ec68ae44dd5943c9",
"title": "Senior Technical Recruiter",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
},
{
"id": "16f556001ce1cef4",
"title": "BizOps Strategist",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
},
{
"id": "8e773a72c1b8e099",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
}
]
}
]
}

View File

@@ -0,0 +1,18 @@
export interface Role {
id: string
title: string
department: string
location: string
applyUrl: string
}
export interface Department {
name: string
key: string
roles: Role[]
}
export interface RolesSnapshot {
fetchedAt: string
departments: Department[]
}

View File

@@ -70,7 +70,7 @@ const translations = {
'zh-CN': '广告 &\n创意工作室'
},
'useCase.gaming': {
en: 'Gaming Industry',
en: 'Gaming',
'zh-CN': '电子游戏'
},
'useCase.ecommerce': {
@@ -120,9 +120,17 @@ const translations = {
en: 'Load a workflow',
'zh-CN': '加载工作流'
},
'getStarted.step2.description': {
en: 'Start from a community template or build your own.',
'zh-CN': '从社区模板开始,或自行构建。'
'getStarted.step2.descriptionPrefix': {
en: 'Start from ',
'zh-CN': '从'
},
'getStarted.step2.descriptionLink': {
en: 'a community template',
'zh-CN': '社区模板'
},
'getStarted.step2.descriptionSuffix': {
en: ' or build your own.',
'zh-CN': '开始,或自行构建。'
},
'getStarted.step3.title': {
en: 'Generate & Iterate',
@@ -201,12 +209,12 @@ const translations = {
'zh-CN': '客户故事'
},
'caseStudy.heading': {
en: 'See Comfy\nin the real world',
'zh-CN': '看看 Comfy\n在真实世界中的应用'
en: 'See ComfyUI\nin the real world',
'zh-CN': '看看 ComfyUI\n在真实世界中的应用'
},
'caseStudy.subheading': {
en: 'Videos & case studies from teams building with Comfy',
'zh-CN': '来自使用 Comfy 构建的团队的视频和案例研究'
en: 'Videos & case studies from teams building with ComfyUI',
'zh-CN': '来自使用 ComfyUI 构建的团队的视频和案例研究'
},
'caseStudy.seeAll': {
en: 'SEE ALL CASE STUDIES',
@@ -734,7 +742,7 @@ const translations = {
'zh-CN': 'Cloud'
},
'cloud.reason.1.title': {
en: 'Powerful GPUs with end-\nto-end security built-in',
en: 'Powerful GPUs with <span class="whitespace-nowrap">end-to-end</span> security <span class="whitespace-nowrap">built-in</span>',
'zh-CN': '强大 GPU\n端到端安全内置'
},
'cloud.reason.1.description': {
@@ -769,7 +777,7 @@ const translations = {
'每个节点都可见。每个设置都可调。ComfyUI 为您提供完整的推理管线。选择您的采样器、调度器、模型链。云端简化了设置并增强了底层硬件。'
},
'cloud.reason.4.title': {
en: 'Community workflows,\nunlimited customization\nthrough pre-installed\ncustom nodes',
en: 'Community workflows,\nunlimited customization\nthrough <span class="whitespace-nowrap">pre-installed</span>\ncustom nodes',
'zh-CN': '社区工作流,\n通过预安装自定义节点\n实现无限自定义'
},
'cloud.reason.4.description': {
@@ -1074,8 +1082,8 @@ const translations = {
'zh-CN': '包含免费版全部能力,另加:'
},
'pricing.plan.standard.feature1': {
en: '60-minute max runtime per workflow',
'zh-CN': '单个工作流最长运行 60 分钟'
en: '30-minute max runtime per workflow',
'zh-CN': '单个工作流最长运行 30 分钟'
},
'pricing.plan.standard.feature2': {
en: 'Add more credits anytime',
@@ -1108,10 +1116,6 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: 'Up to 5 seats per workspace (coming soon!)',
'zh-CN': '每个工作区最多 5 个席位(即将上线)'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1136,10 +1140,6 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: 'Up to 20 seats per workspace (coming soon!)',
'zh-CN': '每个工作区最多 20 个席位(即将上线)'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {
@@ -1195,9 +1195,9 @@ const translations = {
'zh-CN': '单个任务时限'
},
'pricing.included.feature2.description': {
en: 'Each workflow run has a maximum duration of 60 minutes. On the Pro plan, the time limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.',
en: 'On our Standard and Creator plans, each workflow has a maximum run time of 30 minutes. On the Pro plan, the limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.',
'zh-CN':
'每个工作流运行最长60 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
'Standard 和 Creator 计划下,每个工作流最长运行时间30 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
},
'pricing.included.feature3.title': {
en: 'Usage',
@@ -1505,6 +1505,10 @@ const translations = {
// CareersRolesSection
'careers.roles.heading': { en: 'Roles', 'zh-CN': '职位' },
'careers.roles.empty': {
en: 'No open roles right now. Check back soon.',
'zh-CN': '目前暂无开放职位,请稍后再来查看。'
},
// CareersFAQSection
'careers.faq.heading': { en: 'Q&A', 'zh-CN': 'Q&A' },

View File

@@ -17,7 +17,7 @@ interface Props {
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
ogImage = '/og-default.png',
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
} = Astro.props

View File

@@ -6,18 +6,40 @@ import BaseLayout from '../layouts/BaseLayout.astro'
title="404 — Page Not Found — Comfy"
noindex>
<div
class="grid w-full grid-rows-1 place-items-center p-4 h-[calc(100dvh-12rem)]">
<div
class="relative flex size-full min-h-0 items-center justify-center overflow-hidden">
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">
<div class="flex shrink-0 flex-col items-center gap-3 text-center">
<div class="w-[220px] max-w-[60%]">
<svg
viewBox="0 0 311 63"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="404 Page Not Found"
class="block h-auto w-full">
<path d="M11.249 63H29V0C20.8428 0 12.42 6.46924 10.1666 14.4406L0.55317 48.5594C-1.6903 56.5308 3.09184 63 11.249 63Z" fill="#F2FF59"/>
<rect width="65" height="63" transform="translate(29)" fill="#F2FF59"/>
<path d="M45.2357 16.4576V37.3232H47.6171V41.141H45.2357V47H40.6619V41.141H30.1157V36.9074L40.8887 16.4576H45.2357ZM33.5933 37.3232H40.6619V23.9042L33.5933 37.3232ZM61.5204 47.378C55.6614 47.378 52.7886 43.7492 52.7886 37.2854V26.0966C52.7886 19.6706 55.6614 16.0796 61.5204 16.0796C67.3416 16.0796 70.1766 19.6706 70.1766 26.1344V37.3232C70.1766 43.7492 67.3416 47.378 61.5204 47.378ZM57.4758 37.0964L65.2626 22.8458C64.809 20.7668 63.5238 19.8596 61.5204 19.8596C58.8744 19.8596 57.4758 21.3716 57.4758 25.0004V37.0964ZM57.7782 40.9898C58.3452 42.8042 59.5926 43.598 61.5204 43.598C64.1286 43.598 65.4894 42.086 65.4894 38.4194V26.8526L57.7782 40.9898ZM90.321 16.4576V37.3232H92.7024V41.141H90.321V47H85.7472V41.141H75.201V36.9074L85.974 16.4576H90.321ZM78.6786 37.3232H85.7472V23.9042L78.6786 37.3232Z" fill="#211927"/>
<path d="M113.15 48.58L116.26 37.73C116.89 35.52 118.92 34 121.21 34C124.63 34 127.1 37.26 126.17 40.55L125.75 42.06C124.36 46.99 127.32 51 132.37 51H142.85V12.01C137.96 12.27 133.05 16.17 131.7 20.94L130.58 24.9C129.96 27.11 127.95 28.63 125.66 28.63C122.26 28.63 119.8 25.37 120.74 22.1L122.94 14.42C125.22 6.46 120.36 0 112.07 0H94V63C102.28 63 110.85 56.54 113.13 48.58H113.15Z" fill="#F2FF59"/>
<rect width="150" height="39" transform="translate(142.85 12)" fill="#F2FF59"/>
<path d="M144.516 41V22.0928H149.687C153.665 22.0928 155.21 23.7308 155.21 27.9662V28.5746C155.21 32.7866 153.689 34.5416 149.687 34.5416H147.371V41H144.516ZM147.371 32.2952H149.921C151.653 32.2952 152.285 31.4528 152.285 29.4404V27.1004C152.285 25.1114 151.653 24.3392 149.921 24.3392H147.371V32.2952ZM154.605 41L158.326 22.0928H162.257L165.977 41H163.076L162.467 37.49H158.115L157.53 41H154.605ZM158.536 35.0798H162.07L160.361 25.1114L160.291 24.8072L160.221 25.088L158.536 35.0798ZM171.55 41.234C168.204 41.234 166.332 38.8472 166.332 35.1266V28.0832C166.332 24.2456 168.134 21.8588 171.901 21.8588C175.482 21.8588 177.33 24.0116 177.4 27.5918V29.417H174.499L174.475 26.9132C174.429 24.9476 173.446 24.2222 171.925 24.2222C170.1 24.2222 169.234 25.2518 169.234 27.3812V35.6882C169.234 37.7942 170.053 38.8706 171.878 38.8706C173.586 38.8706 174.405 37.958 174.475 36.0158V33.512H171.527V31.289H177.4V41H175.926L175.575 39.1982C174.733 40.4384 173.446 41.234 171.55 41.234ZM179.103 41V22.0928H187.808V24.503H181.958V30.353H187.48V32.7398H181.958V38.5898H187.808V41H179.103ZM194.026 41V22.0928H196.647L202.099 34.7054V22.0928H204.72V41H202.076L196.647 28.3874V41H194.026ZM211.848 41.234C208.104 41.234 206.279 38.8472 206.279 35.0096V28.0832C206.279 24.2456 208.104 21.8588 211.848 21.8588C215.592 21.8588 217.44 24.2456 217.44 28.0832V35.0096C217.44 38.8472 215.592 41.234 211.848 41.234ZM209.18 35.6882C209.18 37.7942 210.069 38.8706 211.848 38.8706C213.626 38.8706 214.515 37.7942 214.515 35.6882V27.428C214.515 25.322 213.626 24.2222 211.848 24.2222C210.069 24.2222 209.18 25.2986 209.18 27.4046V35.6882ZM221.265 41V24.503H217.544V22.0928H227.864V24.503H224.12V41H221.265ZM233.47 41V22.0928H242.245V24.503H236.324V31.2422H242.011V33.629H236.324V41H233.47ZM248.572 41.234C244.828 41.234 243.003 38.8472 243.003 35.0096V28.0832C243.003 24.2456 244.828 21.8588 248.572 21.8588C252.316 21.8588 254.165 24.2456 254.165 28.0832V35.0096C254.165 38.8472 252.316 41.234 248.572 41.234ZM245.905 35.6882C245.905 37.7942 246.794 38.8706 248.572 38.8706C250.351 38.8706 251.24 37.7942 251.24 35.6882V27.428C251.24 25.322 250.351 24.2222 248.572 24.2222C246.794 24.2222 245.905 25.2986 245.905 27.4046V35.6882ZM260.97 41.234C257.249 41.234 255.634 39.3854 255.634 35.4776V22.0928H258.536V36.0626C258.536 38.2622 259.378 38.8706 260.97 38.8706C262.561 38.8706 263.427 38.2622 263.427 36.0626V22.0928H266.305V35.4776C266.305 39.4088 264.667 41.234 260.97 41.234ZM268.148 41V22.0928H270.769L276.221 34.7054V22.0928H278.842V41H276.198L270.769 28.3874V41H268.148ZM280.728 41V22.0928H285.619C289.62 22.0928 291.492 23.4968 291.492 28.0598V35.033C291.492 39.5492 289.62 41 285.619 41H280.728ZM283.583 38.7536H285.642C287.772 38.7536 288.567 38.0048 288.567 35.4542V27.6386C288.567 25.088 287.772 24.3392 285.619 24.3392H283.583V38.7536Z" fill="#211927"/>
<path d="M303.839 12H292.85V51C297.9 51 303.114 46.9952 304.509 42.0606L310.46 20.9394C311.849 16.0048 308.888 12 303.839 12Z" fill="#F2FF59"/>
</svg>
</div>
<p class="text-[15px] tracking-[0.02em] opacity-90">
Looks like this node went off-grid.
</p>
<p
class="font-mono text-sm tracking-[0.05em] opacity-75"
>Score: <span id="scoreNow">0</span><span
class="mx-2.5 opacity-50">|</span
>Best: <span id="scoreBest">0</span></p
>
</div>
<div class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
<svg
id="game"
class="block size-full"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid meet">
<use
class="pointer-events-none text-secondary-mauve"
href="/svg/404-title.svg#title404"
></use>
<g id="board"></g>
<g
fill="currentColor"
@@ -36,19 +58,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
class="cursor-pointer uppercase"
>CLICK TO START</text
>
<text
id="scoreLine"
x="300"
y="232"
font-family="'PP Formula', sans-serif"
font-size="20"
letter-spacing="0.12em"
text-anchor="end"
dominant-baseline="middle"
transform="rotate(-31 400 200)"
display="none"
class="uppercase"
></text>
</g>
</svg>

View File

@@ -1,6 +1,7 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/about/HeroSection.vue'
import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
@@ -8,6 +9,7 @@ import CareersSection from '../components/about/CareersSection.vue'
<BaseLayout title="About Us — Comfy">
<HeroSection client:load />
<StorySection />
<OurValuesSection />
<ValuesSection client:visible />
<CareersSection />

View File

@@ -5,6 +5,20 @@ import RolesSection from '../components/careers/RolesSection.vue'
import WhyJoinSection from '../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -12,7 +26,7 @@ import FAQSection from '../components/common/FAQSection.vue'
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
>
<HeroSection />
<RolesSection client:visible />
<RolesSection departments={departments} client:visible />
<WhyJoinSection client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -1,6 +1,7 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/about/HeroSection.vue'
import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
@@ -8,6 +9,7 @@ import CareersSection from '../../components/about/CareersSection.vue'
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />
<ValuesSection locale="zh-CN" client:visible />
<CareersSection locale="zh-CN" />

View File

@@ -5,6 +5,20 @@ import RolesSection from '../../components/careers/RolesSection.vue'
import WhyJoinSection from '../../components/careers/WhyJoinSection.vue'
import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Ashby fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website ashby:refresh-snapshot` locally and commit the snapshot.'
)
}
const departments = outcome.snapshot.departments
---
<BaseLayout
@@ -12,7 +26,7 @@ import FAQSection from '../../components/common/FAQSection.vue'
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" client:visible />
<RolesSection locale="zh-CN" departments={departments} client:visible />
<WhyJoinSection locale="zh-CN" client:visible />
<TeamPhotosSection client:visible />
<FAQSection

View File

@@ -62,10 +62,15 @@ function requireElement<T extends Element>(
const isSVGSVG = (el: Element): el is SVGSVGElement =>
el instanceof SVGSVGElement
const isSVGG = (el: Element): el is SVGGElement => el instanceof SVGGElement
const isSVGText = (el: Element): el is SVGTextElement =>
el instanceof SVGTextElement
const isHTMLDiv = (el: Element): el is HTMLDivElement =>
el instanceof HTMLDivElement
function isSVGText(el: Element): el is SVGTextElement {
return el instanceof SVGTextElement
}
function isHTMLDiv(el: Element): el is HTMLDivElement {
return el instanceof HTMLDivElement
}
function isHTMLSpan(el: Element): el is HTMLSpanElement {
return el instanceof HTMLSpanElement
}
function setOverlayVisible(el: HTMLElement, visible: boolean) {
el.classList.toggle('hidden', !visible)
@@ -370,7 +375,8 @@ const board = requireElement('board', isSVGG)
const pauseOverlay = requireElement('pauseOverlay', isHTMLDiv)
const svg = requireElement('game', isSVGSVG)
const startCta = requireElement('startCta', isSVGText)
const scoreLine = requireElement('scoreLine', isSVGText)
const scoreNowEl = requireElement('scoreNow', isHTMLSpan)
const scoreBestEl = requireElement('scoreBest', isHTMLSpan)
let best = loadBest()
let waitingToStart = true
@@ -400,6 +406,11 @@ function saveBest(v: number) {
}
}
function updateScoreDisplay() {
scoreNowEl.textContent = String(score)
scoreBestEl.textContent = String(best)
}
const cellsEqual = (a: Cell, b: Cell) => a.i === b.i && a.j === b.j
const inBounds = (c: Cell) => c.i >= 0 && c.j >= 0 && c.i < COLS && c.j < ROWS
@@ -425,6 +436,7 @@ function reset() {
}
rebirthStart = performance.now()
placeFood()
updateScoreDisplay()
render()
ensureAnimationLoop()
}
@@ -467,6 +479,7 @@ function step() {
best = score
saveBest(best)
}
updateScoreDisplay()
placeFood()
onEat()
} else {
@@ -484,14 +497,10 @@ function gameOver() {
window.clearInterval(tickHandle)
tickHandle = null
}
const finalScore = score
const finalBest = best
triggerExplosion()
restartTimeout = window.setTimeout(() => {
waitingToStart = true
startCta.removeAttribute('display')
scoreLine.textContent = `Last Score: ${finalScore} | Best: ${finalBest}`
scoreLine.removeAttribute('display')
reset()
}, EXPLODE_DURATION_MS + 300)
}
@@ -545,6 +554,11 @@ function render() {
return
}
if (!alive) {
board.innerHTML = parts.join('')
return
}
const actors = [
...snake.map((s, idx) => ({
...s,
@@ -678,7 +692,6 @@ function startGame() {
}
waitingToStart = false
startCta.setAttribute('display', 'none')
scoreLine.setAttribute('display', 'none')
if (tickHandle !== null) window.clearInterval(tickHandle)
tickHandle = window.setInterval(step, TICK_MS)
}

View File

@@ -75,14 +75,25 @@
--aspect-ratio-gallery-card: 47/31;
}
@keyframes border-spin {
@property --border-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
@keyframes border-angle-spin {
to {
transform: rotate(360deg);
--border-angle: 360deg;
}
}
@utility animate-border-spin {
animation: border-spin 2s linear infinite;
animation: border-angle-spin 2s linear infinite;
background: conic-gradient(
from var(--border-angle),
color-mix(in srgb, var(--color-primary-comfy-yellow) 4%, transparent) 0%,
var(--color-primary-comfy-yellow) 100%
);
}
@keyframes marquee {
@@ -180,6 +191,14 @@
top: 0.19em;
}
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
(e.g. Low Power Mode). These are decorative background videos. */
video::-webkit-media-controls-start-playback-button,
video::-webkit-media-controls-panel {
display: none !important;
appearance: none;
}
:root {
--site-bg: var(--color-primary-comfy-ink);
--site-bg-soft: color-mix(in srgb, var(--site-bg) 88%, black 12%);

View File

@@ -0,0 +1,130 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './ashby'
import type { RolesSnapshot } from '../data/roles'
import { reportAshbyOutcome, resetAshbyReporterForTests } from './ashby.ci'
function baseSnapshot(): RolesSnapshot {
return {
fetchedAt: new Date().toISOString(),
departments: [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
id: 'x',
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
]
}
}
function freshOutcome(droppedCount = 0): FetchOutcome {
return {
status: 'fresh',
droppedCount,
droppedRoles:
droppedCount === 0
? []
: [{ title: 'Bad Role', reason: 'jobUrl: Invalid url' }],
snapshot: {
fetchedAt: new Date().toISOString(),
departments: [
{
name: 'ENGINEERING',
key: 'engineering',
roles: [
{
id: 'x',
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
]
}
}
}
describe('reportAshbyOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetAshbyReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'ashby-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits nothing on a clean fresh outcome', () => {
reportAshbyOutcome(freshOutcome(0))
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('emits exactly one set of annotations across repeated calls', () => {
reportAshbyOutcome(freshOutcome(1))
reportAshbyOutcome(freshOutcome(1))
expect(writeSpy).toHaveBeenCalledTimes(1)
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby: dropped 1 invalid')
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
})
it('emits ::error for auth failures in a stale outcome', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'HTTP 401 Unauthorized',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby authentication failed')
})
it('emits ::warning for missing-env stale outcomes', () => {
reportAshbyOutcome({
status: 'stale',
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=Ashby integration')
})
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
reportAshbyOutcome({ status: 'failed', reason: 'HTTP 500 Server Error' })
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=Ashby fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportAshbyOutcome(freshOutcome(0))).not.toThrow()
})
})

View File

@@ -0,0 +1,113 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './ashby'
let hasReported = false
export function resetAshbyReporterForTests(): void {
hasReported = false
}
export function reportAshbyOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch {
// Writing the summary is best-effort; do not fail the build if the
// runner's summary file is unavailable (e.g. local dev).
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') {
if (outcome.droppedCount === 0) return []
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
const drops = outcome.droppedRoles
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
.join('%0A')
return [
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
]
}
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
}
return [
`::error title=Ashby fetch failed and no snapshot is available::Cannot build careers page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website ashby:refresh-snapshot\` locally with a valid WEBSITE_ASHBY_API_KEY.%0A 2. Commit apps/website/src/data/ashby-roles.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function staleAnnotation(reason: string): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('missing ')) {
return `::warning title=Ashby integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_ASHBY_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Ashby authentication failed::${escaped}. The WEBSITE_ASHBY_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Open Ashby → Settings → API Keys and confirm the key is active.%0A 2. Update the \`WEBSITE_ASHBY_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
}
if (reason.startsWith('envelope')) {
return `::error title=Ashby schema mismatch::${escaped}. The Ashby API contract has likely changed. Build continues with the snapshot, but future updates will fail until the schema is fixed.%0A%0AAction items:%0A 1. Check https://developers.ashbyhq.com/reference for API changelog.%0A 2. Update apps/website/src/utils/ashby.schema.ts to match the new shape.`
}
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## 💼 Careers (Ashby)\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', '✅ Fresh (fetched from Ashby)'])
rows.push([
'Roles',
String(
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
)
])
rows.push(['Dropped', String(outcome.droppedCount)])
} else if (outcome.status === 'stale') {
rows.push(['Status', '⚠️ Stale (using snapshot — Ashby fetch failed)'])
rows.push([
'Roles',
String(
outcome.snapshot.departments.reduce((n, d) => n + d.roles.length, 0)
)
])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', '❌ Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -0,0 +1,17 @@
import { z } from 'zod'
export const AshbyJobPostingSchema = z.object({
title: z.string().min(1),
department: z.string().optional(),
location: z.string().optional(),
isListed: z.boolean(),
jobUrl: z.string().url(),
applyUrl: z.string().url().optional()
})
export const AshbyJobBoardResponseSchema = z.object({
apiVersion: z.literal('1'),
jobs: z.array(z.unknown())
})
export type AshbyJobPosting = z.infer<typeof AshbyJobPostingSchema>

View File

@@ -0,0 +1,328 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AshbyJobPosting } from './ashby.schema'
import type { RolesSnapshot } from '../data/roles'
import { fetchRolesForBuild, resetAshbyFetcherForTests } from './ashby'
const BASE_URL = 'https://ashby.test'
const BOARD = 'comfy-org'
const KEY = 'abc-123-secret'
function validJob(overrides: Partial<AshbyJobPosting> = {}): unknown {
return {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
isListed: true,
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer',
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/design-engineer/apply',
...overrides
}
}
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
const base: ResponseInit = {
status: 200,
headers: { 'content-type': 'application/json' }
}
return new Response(JSON.stringify(body), { ...base, ...init })
}
function makeSnapshot(roleCount = 2): RolesSnapshot {
const roles = Array.from({ length: roleCount }, (_, i) => ({
id: `snapshot-role-${i}`,
title: `Snapshot Role ${i}`,
department: 'Engineering',
location: 'San Francisco',
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
}))
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
departments: [{ name: 'ENGINEERING', key: 'engineering', roles }]
}
}
function withSnapshotDir(snapshot: RolesSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'ashby-test-'))
const file = join(dir, 'ashby-roles.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
describe('fetchRolesForBuild', () => {
const savedApiKey = process.env.WEBSITE_ASHBY_API_KEY
const savedBoardName = process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
beforeEach(() => {
resetAshbyFetcherForTests()
delete process.env.WEBSITE_ASHBY_API_KEY
delete process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
})
afterEach(() => {
vi.restoreAllMocks()
process.env.WEBSITE_ASHBY_API_KEY = savedApiKey
process.env.WEBSITE_ASHBY_JOB_BOARD_NAME = savedBoardName
})
it('returns fresh when the API succeeds', async () => {
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(0)
expect(outcome.snapshot.departments).toHaveLength(1)
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
/design-engineer\/apply$/
)
})
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
const job = validJob()
delete (job as Record<string, unknown>).applyUrl
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
)
})
it('drops invalid roles individually and keeps the valid ones', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Bad Role', jobUrl: 'not-a-url' })]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(1)
expect(outcome.droppedRoles[0]!.title).toBe('Bad Role')
expect(outcome.snapshot.departments[0]!.roles).toHaveLength(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('renders an empty-but-fresh outcome when hiring is paused', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '1', jobs: [] }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments).toEqual([])
expect(outcome.droppedCount).toBe(0)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('normalizes missing department and location to safe defaults', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const job = validJob()
delete (job as Record<string, unknown>).department
delete (job as Record<string, unknown>).location
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
const [department] = outcome.snapshot.departments
expect(department?.name).toBe('OTHER')
expect(department?.roles[0]?.location).toBe('Remote')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('filters out roles with isListed=false', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
apiVersion: '1',
jobs: [validJob(), validJob({ title: 'Hidden', isListed: false })]
})
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
const titles = outcome.snapshot.departments.flatMap((d) =>
d.roles.map((r) => r.title)
)
expect(titles).not.toContain('Hidden')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns stale with missing env when the snapshot is present', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const fetchImpl = vi.fn()
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^missing /)
expect(fetchImpl).not.toHaveBeenCalled()
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns failed when both env and snapshot are missing', async () => {
const snapshotUrl = withSnapshotDir(null)
const outcome = await fetchRolesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('failed')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on HTTP 401', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 401/)
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('retries 5xx up to the configured limit then falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
expect(fetchImpl).toHaveBeenCalledTimes(4)
expect(sleep).toHaveBeenCalledTimes(3)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('falls back to snapshot on envelope schema mismatch', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({ apiVersion: '2', jobs: [] }))
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^envelope schema/)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('memoizes within a single process', async () => {
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
const opts = {
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
}
const [a, b] = await Promise.all([
fetchRolesForBuild(opts),
fetchRolesForBuild(opts)
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('never writes to the snapshot file on success', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const before = new URL(snapshotUrl.href)
const fs = await import('node:fs')
const initial = fs.readFileSync(before).toString()
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [validJob()] })
)
await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
const after = fs.readFileSync(before).toString()
expect(after).toBe(initial)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on 4xx auth failures for 403', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 403 }))
await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
})

View File

@@ -0,0 +1,299 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import type { AshbyJobPosting } from './ashby.schema'
import type { Department, Role, RolesSnapshot } from '../data/roles'
import bundledSnapshot from '../data/ashby-roles.snapshot.json' with { type: 'json' }
import {
AshbyJobBoardResponseSchema,
AshbyJobPostingSchema
} from './ashby.schema'
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedRole {
title: string
reason: string
}
export type FetchOutcome =
| {
status: 'fresh'
snapshot: RolesSnapshot
droppedCount: number
droppedRoles: DroppedRole[]
}
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchRolesOptions {
apiKey?: string
boardName?: string
baseUrl?: string
timeoutMs?: number
retryDelaysMs?: readonly number[]
fetchImpl?: typeof fetch
snapshotUrl?: URL
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
export function resetAshbyFetcherForTests(): void {
inflight = undefined
}
export function fetchRolesForBuild(
options: FetchRolesOptions = {}
): Promise<FetchOutcome> {
inflight ??= doFetchRolesForBuild(options)
return inflight
}
async function doFetchRolesForBuild(
options: FetchRolesOptions
): Promise<FetchOutcome> {
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
const boardName =
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
if (!apiKey || !boardName) {
return fallback(
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
options.snapshotUrl
)
}
const result = await tryFetchAndParse(apiKey, boardName, options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
departments: result.departments
},
droppedCount: result.droppedRoles.length,
droppedRoles: result.droppedRoles
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
kind: 'ok'
departments: Department[]
droppedRoles: DroppedRole[]
}
interface FetchErr {
kind: 'err'
reason: string
}
async function tryFetchAndParse(
apiKey: string,
boardName: string,
options: FetchRolesOptions
): Promise<FetchOk | FetchErr> {
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
const fetchImpl = options.fetchImpl ?? fetch
const sleep = options.sleep ?? defaultSleep
const url = `${baseUrl}/posting-api/job-board/${encodeURIComponent(
boardName
)}?includeCompensation=false`
const authHeader = `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`
let lastReason = 'unknown error'
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
const response = await callOnce(fetchImpl, url, authHeader, timeoutMs)
if (response.kind === 'err') {
lastReason = response.reason
if (!response.retryable) return response
continue
}
const envelope = AshbyJobBoardResponseSchema.safeParse(response.body)
if (!envelope.success) {
return {
kind: 'err',
reason: `envelope schema validation failed: ${envelope.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
}
}
return parseRoles(envelope.data.jobs)
}
return { kind: 'err', reason: lastReason }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
url: string,
authHeader: string,
timeoutMs: number
): Promise<CallResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(url, {
method: 'GET',
headers: {
Authorization: authHeader,
Accept: 'application/json; version=1'
},
signal: controller.signal
})
if (res.ok) {
return { kind: 'ok', body: await res.json() }
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
retryable
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
} finally {
clearTimeout(timer)
}
}
function parseRoles(jobs: readonly unknown[]): FetchOk {
const valid: AshbyJobPosting[] = []
const droppedRoles: DroppedRole[] = []
for (const raw of jobs) {
const parsed = AshbyJobPostingSchema.safeParse(raw)
if (!parsed.success) {
droppedRoles.push({
title: extractTitle(raw),
reason: parsed.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')
})
continue
}
if (!parsed.data.isListed) continue
valid.push(parsed.data)
}
return { kind: 'ok', departments: groupByDepartment(valid), droppedRoles }
}
function extractTitle(raw: unknown): string {
if (
raw !== null &&
typeof raw === 'object' &&
'title' in raw &&
typeof (raw as { title: unknown }).title === 'string'
) {
return (raw as { title: string }).title
}
return ''
}
const DEFAULT_DEPARTMENT = 'Other'
const DEFAULT_LOCATION = 'Remote'
function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
const byKey = new Map<string, Department>()
for (const job of jobs) {
const displayDepartment = normalizeDepartment(job.department)
const name = displayDepartment.toUpperCase()
const key = slugify(name)
const existing = byKey.get(key)
const role = toDomainRole(job, displayDepartment)
if (existing) {
existing.roles.push(role)
} else {
byKey.set(key, { name, key, roles: [role] })
}
}
return [...byKey.values()].sort((a, b) => a.name.localeCompare(b.name))
}
function toDomainRole(job: AshbyJobPosting, department: string): Role {
const applyUrl = job.applyUrl ?? job.jobUrl
return {
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
title: job.title,
department: capitalize(department),
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
applyUrl
}
}
function normalizeDepartment(raw: string | undefined): string {
const trimmed = (raw ?? '').trim()
return trimmed.length > 0 ? trimmed : DEFAULT_DEPARTMENT
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase()
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<RolesSnapshot | null> {
if (!snapshotUrl) {
return isRolesSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isRolesSnapshot(parsed)) return parsed
return null
} catch {
return null
}
}
function isRolesSnapshot(value: unknown): value is RolesSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as { fetchedAt?: unknown; departments?: unknown }
return (
typeof candidate.fetchedAt === 'string' &&
Array.isArray(candidate.departments)
)
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -8,8 +8,10 @@
"include": [
"src/**/*",
"e2e/**/*",
"scripts/**/*",
"astro.config.ts",
"playwright.config.ts"
"playwright.config.ts",
"vitest.config.ts"
],
"exclude": ["src/**/*.stories.ts"],
"references": [{ "path": "./tsconfig.stories.json" }]

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.{test,spec}.ts'],
globals: false
}
})

View File

@@ -119,7 +119,15 @@
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
],
"properties": {},
"properties": {
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"directory": "checkpoints"
}
]
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
}
],

View File

@@ -518,6 +518,16 @@ export const comfyPageFixture = base.extend<{
await comfyPage.setup()
// Hide agent UI in all tests except those explicitly testing the agent.
// The FAB is positioned over the canvas viewport, which would cause
// unrelated screenshot tests to fail.
if (!testInfo.tags.includes('@agent')) {
await page.addStyleTag({
content:
'[data-testid="agent-fab"],[data-testid="agent-panel"]{display:none!important}'
})
}
if (isVueNodes) {
await comfyPage.vueNodes.waitForNodes()
}

View File

@@ -261,6 +261,7 @@ export class AssetsSidebarTab extends SidebarTab {
// --- Search & filter ---
public readonly searchInput: Locator
public readonly settingsButton: Locator
public readonly filterButton: Locator
// --- View mode ---
public readonly listViewOption: Locator
@@ -299,6 +300,7 @@ export class AssetsSidebarTab extends SidebarTab {
)
this.searchInput = page.getByPlaceholder('Search Assets...')
this.settingsButton = page.getByRole('button', { name: 'View settings' })
this.filterButton = page.getByRole('button', { name: 'Filter by' })
this.listViewOption = page.getByText('List view')
this.gridViewOption = page.getByText('Grid view')
this.sortNewestFirst = page.getByText('Newest first')
@@ -334,6 +336,10 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.getByText(title)
}
filterCheckbox(label: string) {
return this.page.getByRole('checkbox', { name: label })
}
getAssetCardByName(name: string) {
return this.assetCards.filter({ hasText: name })
}
@@ -383,6 +389,16 @@ export class AssetsSidebarTab extends SidebarTab {
.waitFor({ state: 'visible', timeout: 3000 })
}
async openFilterMenu() {
await this.dismissToasts()
await this.filterButton.click()
// Wait for popover content with checkboxes to render
await this.filterCheckbox('Image').waitFor({
state: 'visible',
timeout: 3000
})
}
async rightClickAsset(name: string) {
const card = this.getAssetCardByName(name)
await card.click({ button: 'right' })

View File

@@ -10,6 +10,8 @@ export class SubgraphBreadcrumbPanel {
readonly activeItem: Locator
readonly missingNodesIcon: Locator
readonly blueprintTag: Locator
readonly rootItem: Locator
readonly rootBlueprintTag: Locator
constructor(public readonly page: Page) {
this.root = page.getByTestId(TestIds.breadcrumb.subgraph)
@@ -23,10 +25,10 @@ export class SubgraphBreadcrumbPanel {
TestIds.breadcrumb.missingNodesIcon
)
this.blueprintTag = this.root.getByTestId(TestIds.breadcrumb.blueprintTag)
}
rootItem(): Locator {
return this.page.getByTestId(TestIds.breadcrumb.item('root'))
this.rootItem = page.getByTestId(TestIds.breadcrumb.item('root'))
this.rootBlueprintTag = this.rootItem.getByTestId(
TestIds.breadcrumb.blueprintTag
)
}
subgraphItem(subgraphId: string): Locator {

View File

@@ -0,0 +1,162 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
/**
* E2E coverage for the in-browser agent terminal (AgentFab + FoldablePanel).
*
* The panel is now a Vue-native scrollback (no xterm.js), so the tests
* target the plain DOM directly: the input is a `<textarea>` inside
* `[data-testid="agent-terminal"]`, and the scrollback lives in the same
* container as a list of message blocks. We exercise the deterministic
* shell surface — typing into the textarea runs commands directly through
* the runtime, which is what the LLM ends up calling via `run_shell`.
*/
async function openPanel(comfyPage: ComfyPage): Promise<void> {
const fab = comfyPage.page.getByTestId('agent-fab')
await expect(fab).toBeVisible()
await fab.click()
await expect(comfyPage.page.getByTestId('agent-panel')).toBeVisible()
}
async function readTerminalText(comfyPage: ComfyPage): Promise<string> {
return await comfyPage.page.getByTestId('agent-terminal').innerText()
}
async function typeAndEnter(comfyPage: ComfyPage, text: string): Promise<void> {
const input = comfyPage.page.getByTestId('agent-terminal').locator('textarea')
await input.focus()
await comfyPage.page.keyboard.type(text)
await comfyPage.page.keyboard.press('Enter')
}
test.describe('Agent terminal', { tag: ['@ui', '@agent'] }, () => {
test('FAB opens the panel and shows the COMFY-AI title + prompt', async ({
comfyPage
}) => {
await openPanel(comfyPage)
await expect(comfyPage.page.getByTestId('agent-panel-title')).toHaveText(
'COMFY-AI'
)
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/comfy>/)
})
test('Clicking the FAB again closes the panel', async ({ comfyPage }) => {
await openPanel(comfyPage)
await comfyPage.page.getByTestId('agent-fab').click()
await expect(comfyPage.page.getByTestId('agent-panel')).toBeHidden()
})
test('Enter submits; help command lists built-ins', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'help')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/run-js|cmd-list|comfy/)
})
test('Shift+Enter inserts a literal newline (no submit)', async ({
comfyPage
}) => {
await openPanel(comfyPage)
const input = comfyPage.page
.getByTestId('agent-terminal')
.locator('textarea')
await input.focus()
await comfyPage.page.keyboard.type('echo one')
await comfyPage.page.keyboard.press('Shift+Enter')
await comfyPage.page.keyboard.type('echo two')
// Single submission should run BOTH lines as one multi-line script.
await comfyPage.page.keyboard.press('Enter')
const out = await readTerminalText(comfyPage)
expect(out).toContain('one')
expect(out).toContain('two')
})
test('coreutils: pwd / echo', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'pwd')
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/^\//m)
await typeAndEnter(comfyPage, 'echo hello world')
await expect
.poll(() => readTerminalText(comfyPage))
.toContain('hello world')
})
test('comfy namespace lists subcommands', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'comfy')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/ComfyUI command namespace/)
})
test('run-js evaluates in the page scope', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'run-js return 1 + 2')
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/\b3\b/)
})
test('graph summary reports node count for the active graph', async ({
comfyPage
}) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'graph summary')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/node|count|nodes/i)
})
test('queue-status command returns output', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'queue-status')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/running|pending|queue/i)
})
test('active-workflow reports path / state', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'active-workflow')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/path|modified|persisted|none/i)
})
test('pipe: echo foo | wc -c emits a byte count', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'echo foo | wc -c')
// "foo\n" = 4 bytes
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/\b4\b/)
})
test('unknown command surfaces an error', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'definitely-not-a-real-command-xyz')
await expect
.poll(() => readTerminalText(comfyPage))
.toMatch(/not found|unknown|no such/i)
})
test('Ctrl+O folds and unfolds tool blocks', async ({ comfyPage }) => {
await openPanel(comfyPage)
await typeAndEnter(comfyPage, 'graph summary')
// Tool blocks default to folded — body shouldn't be visible yet.
const panel = comfyPage.page.getByTestId('agent-panel')
await expect(
panel.locator('button:has-text("graph summary")')
).toBeVisible()
// Ctrl+O expands all
await comfyPage.page.keyboard.press('Control+o')
await expect.poll(() => readTerminalText(comfyPage)).toMatch(/nodes|types/i)
// Ctrl+O folds all back — `nodes:` from the body should be hidden again.
await comfyPage.page.keyboard.press('Control+o')
})
})

View File

@@ -772,3 +772,119 @@ test.describe('Assets sidebar - delete confirmation', () => {
await expect(tab.assetCards).toHaveCount(initialCount)
})
})
// ==========================================================================
// 12. Media type filter (cloud-only)
// ==========================================================================
const MIXED_MEDIA_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-image',
create_time: 1000,
execution_start_time: 1000,
execution_end_time: 1010,
preview_output: {
filename: 'photo.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-video',
create_time: 2000,
execution_start_time: 2000,
execution_end_time: 2010,
preview_output: {
filename: 'clip.mp4',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'video'
},
outputs_count: 1
}),
createMockJob({
id: 'job-audio',
create_time: 3000,
execution_start_time: 3000,
execution_end_time: 3010,
preview_output: {
filename: 'track.mp3',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'audio'
},
outputs_count: 1
})
]
// Filter button is guarded by isCloud (compile-time). The cloud CI project
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
// supports authenticated comfyPage setup.
test.describe('Assets sidebar - media type filter', () => {
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MIXED_MEDIA_JOBS)
await comfyPage.assets.mockInputFiles([])
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Filter menu shows media type options', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.openFilterMenu()
await expect(tab.filterCheckbox('Image')).toBeVisible()
await expect(tab.filterCheckbox('Video')).toBeVisible()
await expect(tab.filterCheckbox('Audio')).toBeVisible()
await expect(tab.filterCheckbox('3D')).toBeVisible()
})
test('Unchecking image filter hides image assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = tab.assetCards
await expect(
initialCount,
'All three mixed-media jobs should render'
).toHaveCount(3)
// Open filter menu and enable only image filter (selecting a filter
// restricts to that type only, hiding unselected types)
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
// Only the image asset should remain
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
})
test('Re-enabling filter restores hidden assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
// Enable image filter to restrict to images only
await tab.openFilterMenu()
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
// Uncheck image filter to remove all filters (restores all assets)
await tab.filterCheckbox('Image').click()
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
})
})

View File

@@ -251,7 +251,7 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
await comfyPage.workflow.loadWorkflow(NESTED_WORKFLOW)
await enterNestedSubgraphs(comfyPage)
await expect(subgraphBreadcrumb.panel.rootItem()).toBeAttached()
await expect(subgraphBreadcrumb.panel.rootItem).toBeAttached()
await expect(
subgraphBreadcrumb.panel.subgraphItem(SUBGRAPH_2_ID)
).toContainText(SUBGRAPH_2_NAME)
@@ -265,12 +265,65 @@ test.describe('Subgraph Breadcrumb', { tag: ['@subgraph'] }, () => {
)
})
test(
'shows Blueprint tag only when editing a published blueprint',
{
tag: ['@vue-nodes']
},
async ({ comfyPage, subgraphBreadcrumb }) => {
const { panel } = subgraphBreadcrumb
const blueprintName = `bp-breadcrumb-tag-${Date.now()}`
await test.step('Tag is not shown on a normal workflow', async () => {
await expect(panel.blueprintTag).toHaveCount(0)
})
await test.step('Convert to Subgraph', async () => {
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.contextMenu.openForVueNode(ksampler.header)
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
})
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(subgraphNodeId)
await test.step('Unpublished subgraph does not show the blueprint tag', async () => {
await subgraphNode.centerOnNode()
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await expect(panel.blueprintTag).toHaveCount(0)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await test.step('Publish the subgraph as a blueprint', async () => {
await subgraphNode.click('title')
await comfyPage.subgraph.publishSubgraph(blueprintName)
})
await test.step('Open the blueprint from the node library', async () => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.open()
await tab.getFolder('My Blueprints').click()
await tab.getFolder('User').click()
const blueprintNode = tab.getNode(blueprintName)
await expect(blueprintNode).toBeVisible()
await blueprintNode.getByRole('button', { name: 'Edit' }).click()
})
await test.step('Blueprint tag renders on the root breadcrumb', async () => {
await expect(panel.rootBlueprintTag).toBeVisible()
})
}
)
test('collapses when overflowing and expands when there is room', async ({
comfyPage,
subgraphBreadcrumb
}) => {
const { panel } = subgraphBreadcrumb
const rootItem = panel.rootItem()
const rootItem = panel.rootItem
const subgraph2Item = panel.subgraphItem(SUBGRAPH_2_ID)
const subgraph3Item = panel.subgraphItem(SUBGRAPH_3_ID)
const originalViewport = comfyPage.page.viewportSize()

View File

@@ -196,6 +196,48 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
})
.toStrictEqual(before)
})
test('Selecting a ratio preset auto-enables aspect ratio lock', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' }),
'lock button should start in unlocked state'
).toBeVisible()
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '4:3', exact: true })
.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' }),
'selecting a preset should auto-lock the ratio'
).toBeVisible()
})
test('Unlocking after a preset selection shows Custom in ratio dropdown', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '1:1', exact: true })
.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node.getByRole('combobox'),
'dropdown should revert to Custom after unlock'
).toContainText('Custom')
})
})
test.describe(
@@ -813,6 +855,339 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
await comfyPage.page.unroute('**/api/view**')
}
})
test('Selecting 16:9 ratio adjusts crop height proportionally', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 50,
width: 360,
height: 360
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const expectedHeight = Math.round(before.width / (16 / 9))
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '16:9', exact: true })
.click()
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v || v.width !== before.width) return false
return Math.abs(v.height - expectedHeight) <= 2
},
{ message: '16:9 ratio should adjust crop height proportionally' }
)
.toBe(true)
})
test('Selecting Custom from ratio dropdown unlocks aspect ratio', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 50,
width: 200,
height: 200
})
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: '1:1', exact: true })
.click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'preset selection should lock ratio and show 4 handles'
).toHaveCount(4)
await node.getByRole('combobox').click()
await comfyPage.page
.getByRole('option', { name: 'Custom', exact: true })
.click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'selecting Custom should unlock ratio and restore 8 handles'
).toHaveCount(8)
})
test('Unlock button releases locked ratio and restores all 8 resize handles', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'lock should reduce handles to 4'
).toHaveCount(4)
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' }),
'lock button aria-label should update after locking'
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'unlock should restore all 8 handles'
).toHaveCount(8)
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' }),
'lock button aria-label should revert after unlocking'
).toBeVisible()
})
test('Constrained resize from NW corner adjusts origin and dimensions proportionally', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 150,
y: 140,
width: 200,
height: 150
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const ratio = before.width / before.height
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-nw'),
-45,
-35
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
return (
v.x < before.x &&
v.y < before.y &&
v.width > before.width &&
v.height > before.height &&
Math.abs(v.width / v.height - ratio) < 0.06
)
},
{
message:
'constrained NW resize should grow box and maintain aspect ratio'
}
)
.toBe(true)
})
test('Constrained resize clamps to image boundary while maintaining ratio', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const img = node.locator('img')
await waitForImageNaturalSize(img)
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
nw: el.naturalWidth,
nh: el.naturalHeight
}))
await setCropBounds(comfyPage, 2, {
x: nw - 100,
y: nh - 75,
width: 80,
height: 60
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const initialRatio = before.width / before.height
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-se'),
600,
400
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
const withinBounds = v.x + v.width <= nw && v.y + v.height <= nh
const ratio = v.width / v.height
const ratioPreserved = Math.abs(ratio - initialRatio) < 0.05
return withinBounds && ratioPreserved
},
{
message:
'constrained resize should stay within image boundaries and preserve aspect ratio'
}
)
.toBe(true)
})
test('Constrained NW corner resize clamps at top-left image boundary', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 30,
y: 25,
width: 160,
height: 120
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-nw'),
-400,
-300
)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.x ?? -1, {
message: 'constrained NW resize should clamp x to image boundary'
})
.toBeGreaterThanOrEqual(0)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.y ?? -1, {
message: 'constrained NW resize should clamp y to image boundary'
})
.toBeGreaterThanOrEqual(0)
})
test('Constrained resize enforces minimum crop size', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 100,
y: 100,
width: 60,
height: 60
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-se'),
-300,
-300
)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.width ?? 0, {
message: 'constrained resize should respect minimum width'
})
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.height ?? 0, {
message: 'constrained resize should respect minimum height'
})
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
})
test('Incrementing X in BoundingBox moves crop box right', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 100,
y: 80,
width: 200,
height: 150
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await node
.getByTestId('bounding-box-x')
.getByTestId('increment')
.click()
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.x, {
message: 'incrementing X should move crop right by 1'
})
.toBe(before.x + 1)
})
test('Incrementing Width in BoundingBox increases crop width', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 100,
y: 80,
width: 200,
height: 150
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await node
.getByTestId('bounding-box-width')
.getByTestId('increment')
.click()
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.width, {
message: 'incrementing Width should increase crop width by 1'
})
.toBe(before.width + 1)
})
test('BoundingBox numeric inputs reflect crop position after drag', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 60,
width: 200,
height: 150
})
const xInput = node.getByTestId('bounding-box-x').locator('input')
await expect
.poll(async () => Number(await xInput.inputValue()), {
message: 'X input should show initial crop x value'
})
.toBe(50)
await dragOnLocator(comfyPage, node.getByTestId('crop-overlay'), 40, 20)
await expect
.poll(async () => Number(await xInput.inputValue()), {
message: 'X input should update after crop drag'
})
.toBeGreaterThan(50)
})
}
)

90
build/plugins/agentLog.ts Normal file
View File

@@ -0,0 +1,90 @@
import { appendFileSync, existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import type { Plugin } from 'vite'
/**
* Dev-only Vite plugin: accept POSTs to /__agent-log and append each
* JSONL line to a per-session file under ./tmp/agent-logs/.
*
* Filename: ./tmp/agent-logs/<YYYY-MM-DD>-<sessionId>.jsonl
* - <sessionId> is the 8-char id assigned in the browser logger and
* attached to every entry. One file per page load makes individual
* conversations trivially diff-able and grep-able without sifting
* through a daily mixed log.
* - Entries without a sessionId fall back to '<date>-orphan.jsonl' so
* unattributed lines don't get silently dropped.
*
* GET /__agent-log → returns the directory + a 1-line summary of recent
* session files (debugging aid).
*
* No-op in production builds (apply: 'serve'). Same origin as the Vite
* dev server so the browser-side logger can POST with a simple fetch().
*/
export function agentLogPlugin(): Plugin {
const LOG_DIR = join(process.cwd(), 'tmp', 'agent-logs')
return {
name: 'agent-log',
apply: 'serve',
configureServer(server) {
server.middlewares.use('/__agent-log', (req, res) => {
if (req.method === 'GET') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ dir: LOG_DIR, mode: 'per-session' }))
return
}
if (req.method !== 'POST') {
res.statusCode = 405
res.end()
return
}
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
try {
if (!existsSync(LOG_DIR)) {
mkdirSync(LOG_DIR, { recursive: true })
}
const date = new Date().toISOString().slice(0, 10)
const body = Buffer.concat(chunks).toString('utf8')
// Group lines by sessionId so a single batch carrying multiple
// sessions (rare but possible) lands in the right files.
const buckets = new Map<string, string[]>()
for (const raw of body.split('\n')) {
const line = raw.trim()
if (!line) continue
let sessionId = 'orphan'
try {
const parsed = JSON.parse(line) as { sessionId?: string }
if (
parsed.sessionId &&
/^[A-Za-z0-9-]{1,64}$/.test(parsed.sessionId)
) {
sessionId = parsed.sessionId
}
} catch {
// Keep raw text in the orphan bucket; don't drop it.
}
const arr = buckets.get(sessionId) ?? []
arr.push(line)
buckets.set(sessionId, arr)
}
for (const [sessionId, lines] of buckets) {
const file = join(LOG_DIR, `${date}-${sessionId}.jsonl`)
appendFileSync(file, lines.join('\n') + '\n', 'utf8')
}
res.statusCode = 204
res.end()
} catch (err) {
res.statusCode = 500
res.end(err instanceof Error ? err.message : String(err))
}
})
})
}
}
}

View File

@@ -1 +1,2 @@
export { agentLogPlugin } from './agentLog'
export { comfyAPIPlugin } from './comfyAPIPlugin'

View File

@@ -0,0 +1,164 @@
# 9. Frontend-only In-app Agent + Future Local-Agent Bridge
Date: 2026-04-26
## Status
Proposed
## Context
PR #11547 introduces an experimental in-browser agent (`ComfyAI`) that
lets users drive ComfyUI with natural language. It lives entirely in
`src/agent/` and runs in the SPA — prompt assembly, tool execution
(browser-side `run-js` + Comfy API calls), message storage, and IndexedDB
chat history all happen client-side. The LLM is reached directly from
the browser via the user's API key (OpenAI / OpenRouter / any
OpenAI-compatible gateway), with optional Comfy Cloud auth for the
small set of cloud nodes (Tripo / Tencent / Meshy / Gemini).
This frontend-only architecture is deliberate. It keeps the deployment
story trivial (no backend changes), keeps the user's API key out of
ComfyUI's backend, and works whether the backend is local or remote.
But it raises a coordination problem the moment users want their
**other agents** — Claude Code, a self-hosted CLI agent, a teammate's
agent on a different machine — to participate in the same conversation,
see the same workflow state, or take actions on the user's behalf.
The forces at play:
- **Privacy**: API keys must not leak to ComfyUI's backend or to other
observers. The frontend-only model makes this trivially true today.
- **Source of truth for graph state**: the canonical workflow lives in
LiteGraph's in-memory tree inside the SPA. Backend has the queue +
history but doesn't track unsaved edits. Any other agent that wants
current state must either read from the SPA or read a snapshot the
SPA publishes.
- **Tool affordance**: the agent's `run_shell` tool currently executes
in the browser page context (DOM, stores, fetch with same-origin
cookies). A local agent has none of that — it would need either a
separate REST surface or to drive the SPA remotely.
- **Identity**: the SPA can hold a Comfy Cloud token; a local agent is
a separate principal and should hold its own credentials.
- **Versioning**: the moment we expose a wire format, breaking changes
hurt. Whatever we ship first becomes the contract.
The question this ADR exists to answer: **how should a local agent
participate in the in-app agent's session, given the frontend-only
constraint we want to preserve?**
## Decision
**Short term (this PR and the next few): keep the agent strictly
frontend-only.** Do not add any backend session state, message
relaying, or local-agent bridge. The current architecture is small,
auditable, and removes whole categories of risk.
**Long term: when local-agent integration is taken on, prefer Option C
("opt-in publish bus with execution staying in the SPA") over the
alternatives.** The detailed shape:
1. Define a small JSON-RPC schema for "agent context" — current
workflow id + serialized graph, last N messages, last K tool
invocations, agent settings (model + base URL only, never key).
Versioned from the start.
2. SPA exposes a "Share session" toggle in agent settings. When on,
it publishes that snapshot to a configurable WS endpoint
(default: `ws://localhost:7437/agent`). The user explicitly opts
in per session.
3. Provide a tiny reference subscriber library that local agents use
to consume. They get **read-only access by default**; getting
write access (post a message back into the user's panel) requires
the SPA to authorize via a one-time pairing code shown to the
user.
4. **Tool execution stays in the SPA.** Local agents can _propose_
actions ("run this run-js"); the SPA executes and streams the
result back. The local agent is a peer that suggests, not an
actor that mutates.
**Alternatives considered and rejected (for now):**
- **Option A — ComfyUI backend as session broker.** Push messages to
the running ComfyUI server, local agents subscribe via WS or
polling. Rejected because ComfyUI is meant to be largely stateless,
adding session storage is scope creep, and it puts API keys / chat
content in front of the backend (privacy regression).
- **Option B — browser extension or local sidecar daemon.** A
companion daemon reads the SPA's IndexedDB via Chrome DevTools
Protocol, or the SPA opens a localhost WS to it. Rejected as the
default path because of the cross-platform packaging burden and
because it doesn't help when the local agent runs on a different
machine than the SPA.
**Comfy Cloud creds reuse (a related future work item):** when the
user is signed into Comfy Cloud (the `auth_token_comfy_org` flow we
already use for Tripo/Gemini), the agent could optionally route LLM
calls through a Comfy-managed inference endpoint instead of OpenAI
direct. This would gate naturally on the same auth as the cloud
nodes and simplifies onboarding for users who don't have their own
OpenAI/OpenRouter key. Out of scope here, but worth noting because
it interacts with the local-agent identity story above.
## Consequences
### Positive
- **No backend changes today.** PR #11547 lands without touching
ComfyUI core. Reviewers don't need to evaluate session-state
infrastructure they didn't ask for.
- **Privacy posture stays strong.** API keys + chat content stay in
the user's browser; ComfyUI backend continues to see only what it
always saw (queue prompts, file uploads).
- **Future local-agent path is clear** without committing to a
protocol prematurely. When we build it, the SPA stays the
source-of-truth + execution sandbox; the local agent is a peer that
suggests. Mirrors how editors coexist with Claude Code, GitHub
Copilot, etc.
- **Headroom for multi-subscriber.** Option C naturally supports
agent + observer + log-tap subscribers with the same protocol —
useful for future debugging tools.
- **Versioned wire format** means breaking changes are explicit.
### Negative
- **Local agents have no participation today.** Users who want their
Claude Code session to see what they're doing in ComfyUI need to
copy/paste workflow JSON manually.
- **When we do build the bridge, it's net-new infrastructure** — a
WS server, a pairing flow, a versioning policy, a reference
subscriber library. Not trivial.
- **Tool execution stays in the SPA** even after the bridge ships,
which means a local agent on a different machine can't `run-js`
against the user's session without the SPA being open. (We accept
this as a privacy + simplicity tradeoff.)
- **The "Share session" toggle is yet another decision the user has
to make**, with non-obvious risks. Mitigations: clear UX copy,
default off, pairing-code requirement for write access.
## Notes
- The frontend-only constraint also drove several smaller decisions
in the PR that are worth recording briefly:
- Reasoning guardrails (`PROMISSORY_PATTERN`, `vetScript`,
`verifyClaims`) live in the SPA in `src/agent/llm/session.ts`,
not in a separate service. They survive prompt drift because
they're code, not text.
- Chat history is persisted via `useIDBKeyval` to IndexedDB. This
is a per-browser-profile store; switching profiles or clearing
site data wipes history. Acceptable for the experimental phase;
if local-agent bridge ships, the snapshot the SPA publishes
becomes another effective "external" history mechanism.
- The default LLM is `gpt-5.4` via OpenAI's official API. The
settings panel exposes a base-URL field so users can target
OpenRouter (`https://openrouter.ai/api/v1`) or any OpenAI-compatible
gateway. This base-URL flexibility also makes Option C's "Comfy
Cloud as inference endpoint" trivially achievable later — it's just
another base-URL choice.
- Concrete near-term TODOs flagged by this PR's stress-testing,
_not_ covered by this ADR but related:
- Layer 3 of the reasoning guardrails (structured JSON answers
with provenance) needs SDK plumbing to surface tool-call IDs
alongside text. Currently deferred.
- Verifier registry and shell-idiom blocklist are open
registries; entries grow as new failure modes surface in real
use.

View File

@@ -8,16 +8,17 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| ADR | Title | Status | Date |
| ---------------------------------------------------------- | ------------------------------------------------------ | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-frontend-only-agent-and-local-agent-bridge.md) | Frontend-only In-app Agent + Future Local-Agent Bridge | Proposed | 2026-04-26 |
## Creating a New ADR

View File

@@ -0,0 +1,78 @@
# 案 B: バックエンドの `/features` に `comfy_api_base` を追加する
## 背景
ComfyUI バックエンドは `--comfy-api-base` CLI フラグで Comfy Cloud の API ベース URLprod / staging / カスタム)を選択する。
フロントエンドは `__USE_PROD_CONFIG__` ビルド時定数で同じ値を選ぶ。
両者が食い違うと、フロントエンドが発行した Firebase トークン(または API キー)が
バックエンド経由で別の環境に投げられ、認証や課金が落ちる。
現状の検出方法(案 A、`src/views/ConnectionPanelView.vue`)は
`/api/system_stats``system.argv`CLI 全引数)から `--comfy-api-base` を grep するもの。
動くが脆い:
- 引数の書式(`--flag VALUE` vs `--flag=VALUE`)に依存する
- バックエンド側の CLI シグネチャが変わると壊れる
- 「公開 API ではない情報」を検出ロジックに使っている
## 提案
ComfyUI 本体の `/features` エンドポイントに `comfy_api_base` を追加する。
`/features` はすでに「構造化された機能/設定の公開 API」という位置付けがあり、ここに含めるのが自然。
### バックエンドの実装スケッチ
```python
# tmp/ComfyUI/comfy_api/feature_flags.py:65 付近
def get_server_features() -> dict[str, Any]:
from comfy.cli_args import args
return {
...,
"comfy_api_base": args.comfy_api_base,
}
```
### フロントエンドの変更
```ts
// 例: src/platform/connectionPanel/ あたりに移設
const features = await fetch(`${base}/api/features`).then((r) => r.json())
const backendCloudBase =
features.comfy_api_base ?? parseBackendCloudBase(stats.system?.argv)
```
`features.comfy_api_base` を優先し、未定義の場合のみ `argv` フォールバックを使う。
## メリット
- 構造化された公開 API になり、CLI 変更の影響を受けない
- 拡張機能 / カスタムノードからも安定して参照できる
- 既存の `/features` パターン(ファースト クラスのバックエンド能力公開)に合致
- フロントエンドの検出コードが自明になる
## デメリット
- `Comfy-Org/ComfyUI` 本体への PR とリリースが必要
- リリース前は案 A をフォールバックとして残す必要がある
- `comfy_api_base` を「公開してよい情報」と扱う合意が必要
(カスタム URL を使うユーザーには内部 URL が露出することになる)
## ロードマップ
1. **案 A をフロントエンドに実装(このコミット)**
- `ConnectionPanelView.vue``/system_stats``argv` を解析
- 不一致を検出した場合は黄色の警告を表示
2. `Comfy-Org/ComfyUI``/features` 拡張 PR を提出
- `comfy_api/feature_flags.py:65``comfy_api_base` を追加
3. 本体リリース後、フロントエンドを `features.comfy_api_base` 優先に切替
- `argv` フォールバックは互換性のために残す
4. 数バージョン後、`argv` フォールバックを削除
## 関連ファイル
- ComfyUI 本体: `comfy/cli_args.py:229``--comfy-api-base` 引数定義(デフォルト `https://api.comfy.org`
- ComfyUI 本体: `comfy_api/feature_flags.py:65``get_server_features()` の現状
- ComfyUI 本体: `server.py:646-685``/system_stats` ハンドラ(`argv` を返している)
- フロントエンド: `src/config/comfyApi.ts:21-31``getComfyApiBaseUrl()`(フロント側のビルド時定数)
- フロントエンド: `src/views/ConnectionPanelView.vue` — 案 A 実装場所
- フロントエンド: `src/platform/remoteConfig/refreshRemoteConfig.ts``/features` 既存利用

View File

@@ -27,7 +27,12 @@ const commonGlobals = {
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
__IS_NIGHTLY__: 'readonly',
__CI_BRANCH__: 'readonly',
__CI_PR_NUMBER__: 'readonly',
__CI_PR_AUTHOR__: 'readonly',
__CI_RUN_ID__: 'readonly',
__CI_JOB_ID__: 'readonly'
} as const
const settings = {

5
global.d.ts vendored
View File

@@ -2,6 +2,11 @@ declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __COMFYUI_FRONTEND_COMMIT__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __CI_BRANCH__: string
declare const __CI_PR_NUMBER__: string
declare const __CI_PR_AUTHOR__: string
declare const __CI_RUN_ID__: string
declare const __CI_JOB_ID__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.44.9",
"version": "1.44.10",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -56,6 +56,7 @@
"clean": "nx reset"
},
"dependencies": {
"@ai-sdk/openai": "catalog:",
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
@@ -89,6 +90,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
"ai": "catalog:",
"algoliasearch": "catalog:",
"axios": "catalog:",
"chart.js": "^4.5.0",
@@ -101,6 +103,7 @@
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "catalog:",
"idb-keyval": "catalog:",
"jsonata": "catalog:",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
@@ -110,6 +113,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"shell-quote": "catalog:",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
@@ -147,6 +151,7 @@
"@types/jsdom": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/shell-quote": "catalog:",
"@types/three": "catalog:",
"@vitejs/plugin-vue": "catalog:",
"@vitest/coverage-v8": "catalog:",

310
pnpm-lock.yaml generated
View File

@@ -6,12 +6,15 @@ settings:
catalogs:
default:
'@ai-sdk/openai':
specifier: ^3.0.53
version: 3.0.53
'@alloc/quick-lru':
specifier: ^5.2.0
version: 5.2.0
'@astrojs/check':
specifier: ^0.9.8
version: 0.9.8
version: 0.9.9
'@astrojs/sitemap':
specifier: ^3.7.1
version: 3.7.1
@@ -159,6 +162,9 @@ catalogs:
'@types/semver':
specifier: ^7.7.0
version: 7.7.0
'@types/shell-quote':
specifier: ^1.7.5
version: 1.7.5
'@types/three':
specifier: ^0.169.0
version: 0.169.0
@@ -183,6 +189,9 @@ catalogs:
'@webgpu/types':
specifier: ^0.1.66
version: 0.1.66
ai:
specifier: ^6.0.168
version: 6.0.168
algoliasearch:
specifier: ^5.21.0
version: 5.21.0
@@ -251,13 +260,16 @@ catalogs:
version: 16.5.0
gsap:
specifier: ^3.14.2
version: 3.14.2
version: 3.15.0
happy-dom:
specifier: ^20.0.11
version: 20.0.11
husky:
specifier: ^9.1.7
version: 9.1.7
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
jiti:
specifier: 2.6.1
version: 2.6.1
@@ -272,7 +284,7 @@ catalogs:
version: 6.3.1
lenis:
specifier: ^1.3.21
version: 1.3.21
version: 1.3.23
lint-staged:
specifier: ^16.2.7
version: 16.4.0
@@ -284,7 +296,7 @@ catalogs:
version: 2.71.0
monocart-coverage-reports:
specifier: ^2.12.9
version: 2.12.9
version: 2.12.11
nx:
specifier: 22.6.1
version: 22.6.1
@@ -324,6 +336,9 @@ catalogs:
rollup-plugin-visualizer:
specifier: ^6.0.4
version: 6.0.4
shell-quote:
specifier: ^1.8.3
version: 1.8.3
storybook:
specifier: ^10.2.10
version: 10.2.10
@@ -416,6 +431,9 @@ importers:
.:
dependencies:
'@ai-sdk/openai':
specifier: 'catalog:'
version: 3.0.53(zod@3.25.76)
'@alloc/quick-lru':
specifier: 'catalog:'
version: 5.2.0
@@ -502,7 +520,7 @@ importers:
version: 14.2.0(vue@3.5.13(typescript@5.9.3))
'@vueuse/integrations':
specifier: 'catalog:'
version: 14.2.0(axios@1.13.5)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.3))
version: 14.2.0(axios@1.13.5)(fuse.js@7.0.0)(idb-keyval@6.2.2)(vue@3.5.13(typescript@5.9.3))
'@vueuse/router':
specifier: ^14.2.0
version: 14.2.1(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))
@@ -515,6 +533,9 @@ importers:
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
ai:
specifier: 'catalog:'
version: 6.0.168(zod@3.25.76)
algoliasearch:
specifier: 'catalog:'
version: 5.21.0
@@ -551,6 +572,9 @@ importers:
glob:
specifier: 'catalog:'
version: 13.0.6
idb-keyval:
specifier: 'catalog:'
version: 6.2.2
jsonata:
specifier: 'catalog:'
version: 2.1.0
@@ -578,6 +602,9 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.4
shell-quote:
specifier: 'catalog:'
version: 1.8.3
three:
specifier: ^0.170.0
version: 0.170.0
@@ -684,6 +711,9 @@ importers:
'@types/semver':
specifier: 'catalog:'
version: 7.7.0
'@types/shell-quote':
specifier: 'catalog:'
version: 1.7.5
'@types/three':
specifier: 'catalog:'
version: 0.169.0
@@ -770,7 +800,7 @@ importers:
version: 2.71.0
monocart-coverage-reports:
specifier: 'catalog:'
version: 2.12.9
version: 2.12.11
nx:
specifier: 'catalog:'
version: 22.6.1
@@ -945,20 +975,23 @@ importers:
version: 1.0.0-beta.4(typescript@5.9.3)
gsap:
specifier: 'catalog:'
version: 3.14.2
version: 3.15.0
lenis:
specifier: 'catalog:'
version: 1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
version: 1.3.23(react@19.2.4)(vue@3.5.13(typescript@5.9.3))
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
zod:
specifier: 'catalog:'
version: 3.25.76
devDependencies:
'@astrojs/check':
specifier: 'catalog:'
version: 0.9.8(prettier@3.7.4)(typescript@5.9.3)
version: 0.9.9(prettier@3.7.4)(typescript@5.9.3)
'@astrojs/vue':
specifier: 'catalog:'
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)
'@playwright/test':
specifier: 'catalog:'
version: 1.58.1
@@ -967,13 +1000,19 @@ importers:
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
astro:
specifier: 'catalog:'
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
version: 5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
tailwindcss:
specifier: 'catalog:'
version: 4.2.0
tsx:
specifier: 'catalog:'
version: 4.19.4
typescript:
specifier: 'catalog:'
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
packages/design-system:
dependencies:
@@ -1046,6 +1085,28 @@ packages:
'@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
'@ai-sdk/gateway@3.0.104':
resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai@3.0.53':
resolution: {integrity: sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.23':
resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.8':
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
'@alcalzone/ansi-tokenize@0.2.5':
resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==}
engines: {node: '>=18'}
@@ -1121,11 +1182,11 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@astrojs/check@0.9.8':
resolution: {integrity: sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw==}
'@astrojs/check@0.9.9':
resolution: {integrity: sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==}
hasBin: true
peerDependencies:
typescript: ^5.0.0
typescript: ^5.0.0 || ^6.0.0
'@astrojs/compiler@2.13.1':
resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==}
@@ -1133,8 +1194,8 @@ packages:
'@astrojs/internal-helpers@0.7.6':
resolution: {integrity: sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==}
'@astrojs/language-server@2.16.6':
resolution: {integrity: sha512-N990lu+HSFiG57owR0XBkr02BYMgiLCshLf+4QG4v6jjSWkBeQGnzqi+E1L08xFPPJ7eEeXnxPXGLaVv5pa4Ug==}
'@astrojs/language-server@2.16.7':
resolution: {integrity: sha512-b64bWT74Vq/ORcSqW7TdIjjpB6hcl+Ei/lMANIUaAGlLPiYNtPTRI/j2tzvugT+LoVwfJtE2Ukq/t2OGCyEtfQ==}
hasBin: true
peerDependencies:
prettier: ^3.0.0
@@ -4481,6 +4542,9 @@ packages:
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
'@types/shell-quote@1.7.5':
resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==}
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
@@ -4741,6 +4805,10 @@ packages:
vue-router:
optional: true
'@vercel/oidc@3.2.0':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
'@vitejs/plugin-vue-jsx@4.2.0':
resolution: {integrity: sha512-DSTrmrdLp+0LDNF77fqrKfx7X0ErRbOcUAgJL/HbSesqQwoUvUQ4uYQqaex+rovqgGcoPqVk+AwUh3v9CuiYIw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -5120,6 +5188,12 @@ packages:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ai@6.0.168:
resolution: {integrity: sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -5678,8 +5752,8 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
console-grid@2.2.3:
resolution: {integrity: sha512-+mecFacaFxGl+1G31IsCx41taUXuW2FxX+4xIE0TIPhgML+Jb9JFcBWGhhWerd1/vhScubdmHqTwOhB0KCUUAg==}
console-grid@2.2.4:
resolution: {integrity: sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==}
constantinople@4.0.1:
resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==}
@@ -6373,6 +6447,10 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
eventsource-parser@3.0.8:
resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==}
engines: {node: '>=18.0.0'}
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
@@ -6530,6 +6608,10 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
foreground-child@4.0.3:
resolution: {integrity: sha512-yeXZaNbCBGaT9giTpLPBdtedzjwhlJBUoL/R4BVQU5mn0TQXOHwVIl1Q2DMuBIdNno4ktA1abZ7dQFVxD6uHxw==}
engines: {node: '>=16'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
@@ -6707,8 +6789,8 @@ packages:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
gsap@3.14.2:
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
h3@1.15.10:
resolution: {integrity: sha512-YzJeWSkDZxAhvmp8dexjRK5hxziRO7I9m0N53WhvYL5NiWfkUkzssVzY9jvGu0HBoLFW6+duYmNSn6MaZBCCtg==}
@@ -6846,6 +6928,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -7236,6 +7321,9 @@ packages:
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -7321,8 +7409,8 @@ packages:
resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==}
engines: {node: '>=18'}
lenis@1.3.21:
resolution: {integrity: sha512-RXWTYm7KQE4Kv8ezxL6wvK0Oiv7aRr6FDo+eNaaniTeu7pLdHokqMIJ5CXO4x5ezvd+9ONdpSFkprLpXsVWmEw==}
lenis@1.3.23:
resolution: {integrity: sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
@@ -7593,8 +7681,8 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
lz-utils@2.1.0:
resolution: {integrity: sha512-CMkfimAypidTtWjNDxY8a1bc1mJdyEh04V2FfEQ5Zh8Nx4v7k850EYa+dOWGn9hKG5xOyHP5MkuduAZCTHRvJw==}
lz-utils@2.1.1:
resolution: {integrity: sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==}
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
@@ -7872,12 +7960,12 @@ packages:
resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==}
engines: {node: '>=18.0.0'}
monocart-coverage-reports@2.12.9:
resolution: {integrity: sha512-vtFqbC3Egl4nVa1FSIrQvMPO6HZtb9lo+3IW7/crdvrLNW2IH8lUsxaK0TsKNmMO2mhFWwqQywLV2CZelqPgwA==}
monocart-coverage-reports@2.12.11:
resolution: {integrity: sha512-yo4/FdUdFIWoc9OjhBZCNXM95tYHS4e8nov9Q3AGbpvteT/W5aQSc4B+Q0nhmedZFvjvm3BUH/Xu9GT2n/0wkw==}
hasBin: true
monocart-locator@1.0.2:
resolution: {integrity: sha512-v8W5hJLcWMIxLCcSi/MHh+VeefI+ycFmGz23Froer9QzWjrbg4J3gFJBuI/T1VLNoYxF47bVPPxq8ZlNX4gVCw==}
monocart-locator@1.0.3:
resolution: {integrity: sha512-pe29W2XAoA1WQmZZqxXoP7s06ZEXUhcb81086v68cqjk1HnVL7Q/iU/WJnnetxjPcLqwb4qG8vaSGUOMQU602g==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
@@ -8246,6 +8334,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
@@ -8830,6 +8922,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
@@ -9161,6 +9257,10 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinypool@2.1.0:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
@@ -10230,6 +10330,30 @@ snapshots:
'@adobe/css-tools@4.4.4': {}
'@ai-sdk/gateway@3.0.104(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
'@vercel/oidc': 3.2.0
zod: 3.25.76
'@ai-sdk/openai@3.0.53(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/provider-utils@4.0.23(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.8
zod: 3.25.76
'@ai-sdk/provider@3.0.8':
dependencies:
json-schema: 0.4.0
'@alcalzone/ansi-tokenize@0.2.5':
dependencies:
ansi-styles: 6.2.3
@@ -10339,9 +10463,9 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
'@astrojs/check@0.9.8(prettier@3.7.4)(typescript@5.9.3)':
'@astrojs/check@0.9.9(prettier@3.7.4)(typescript@5.9.3)':
dependencies:
'@astrojs/language-server': 2.16.6(prettier@3.7.4)(typescript@5.9.3)
'@astrojs/language-server': 2.16.7(prettier@3.7.4)(typescript@5.9.3)
chokidar: 4.0.3
kleur: 4.1.5
typescript: 5.9.3
@@ -10354,7 +10478,7 @@ snapshots:
'@astrojs/internal-helpers@0.7.6': {}
'@astrojs/language-server@2.16.6(prettier@3.7.4)(typescript@5.9.3)':
'@astrojs/language-server@2.16.7(prettier@3.7.4)(typescript@5.9.3)':
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/yaml2ts': 0.2.3
@@ -10364,7 +10488,7 @@ snapshots:
'@volar/language-server': 2.4.28
'@volar/language-service': 2.4.28
muggle-string: 0.4.1
tinyglobby: 0.2.15
tinyglobby: 0.2.16
volar-service-css: 0.0.70(@volar/language-service@2.4.28)
volar-service-emmet: 0.0.70(@volar/language-service@2.4.28)
volar-service-html: 0.0.70(@volar/language-service@2.4.28)
@@ -10427,12 +10551,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)':
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)':
dependencies:
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vue/compiler-sfc': 3.5.28
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
astro: 5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
vue: 3.5.13(typescript@5.9.3)
@@ -13798,6 +13922,8 @@ snapshots:
'@types/semver@7.7.0': {}
'@types/shell-quote@1.7.5': {}
'@types/stats.js@0.17.3': {}
'@types/three@0.169.0':
@@ -14035,6 +14161,8 @@ snapshots:
vue: 3.5.13(typescript@5.9.3)
vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3))
'@vercel/oidc@3.2.0': {}
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@babel/core': 7.29.0
@@ -14105,6 +14233,14 @@ snapshots:
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
@@ -14139,7 +14275,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -14461,7 +14597,7 @@ snapshots:
'@vueuse/shared': 14.2.0(vue@3.5.13(typescript@5.9.3))
vue: 3.5.13(typescript@5.9.3)
'@vueuse/integrations@14.2.0(axios@1.13.5)(fuse.js@7.0.0)(vue@3.5.13(typescript@5.9.3))':
'@vueuse/integrations@14.2.0(axios@1.13.5)(fuse.js@7.0.0)(idb-keyval@6.2.2)(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@vueuse/core': 14.2.0(vue@3.5.13(typescript@5.9.3))
'@vueuse/shared': 14.2.0(vue@3.5.13(typescript@5.9.3))
@@ -14469,6 +14605,7 @@ snapshots:
optionalDependencies:
axios: 1.13.5
fuse.js: 7.0.0
idb-keyval: 6.2.2
'@vueuse/metadata@12.8.2': {}
@@ -14557,6 +14694,14 @@ snapshots:
dependencies:
humanize-ms: 1.2.1
ai@6.0.168(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.104(zod@3.25.76)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ajv-draft-04@1.0.0(ajv@8.13.0):
optionalDependencies:
ajv: 8.13.0
@@ -14753,7 +14898,7 @@ snapshots:
astral-regex@2.0.0: {}
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2):
astro@5.18.1(@types/node@25.0.3)(idb-keyval@6.2.2)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
@@ -14808,7 +14953,7 @@ snapshots:
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.4
unstorage: 1.17.4(idb-keyval@6.2.2)
vfile: 6.0.3
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
@@ -15268,7 +15413,7 @@ snapshots:
consola@3.4.2: {}
console-grid@2.2.3: {}
console-grid@2.2.4: {}
constantinople@4.0.1:
dependencies:
@@ -16106,6 +16251,8 @@ snapshots:
eventemitter3@5.0.4: {}
eventsource-parser@3.0.8: {}
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@@ -16208,6 +16355,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
fflate@0.4.8: {}
fflate@0.8.2: {}
@@ -16310,6 +16461,10 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
foreground-child@4.0.3:
dependencies:
signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data@4.0.5:
@@ -16509,7 +16664,7 @@ snapshots:
section-matter: 1.0.0
strip-bom-string: 1.0.0
gsap@3.14.2: {}
gsap@3.15.0: {}
h3@1.15.10:
dependencies:
@@ -16715,6 +16870,8 @@ snapshots:
husky@9.1.7: {}
idb-keyval@6.2.2: {}
idb@7.1.1: {}
ieee754@1.2.1: {}
@@ -17104,6 +17261,8 @@ snapshots:
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json-stable-stringify@1.3.0:
@@ -17200,7 +17359,7 @@ snapshots:
dependencies:
package-json: 10.0.1
lenis@1.3.21(react@19.2.4)(vue@3.5.13(typescript@5.9.3)):
lenis@1.3.23(react@19.2.4)(vue@3.5.13(typescript@5.9.3)):
optionalDependencies:
react: 19.2.4
vue: 3.5.13(typescript@5.9.3)
@@ -17409,7 +17568,7 @@ snapshots:
lz-string@1.5.0: {}
lz-utils@2.1.0: {}
lz-utils@2.1.1: {}
magic-string-ast@1.0.3:
dependencies:
@@ -17883,22 +18042,22 @@ snapshots:
modern-tar@0.7.3: {}
monocart-coverage-reports@2.12.9:
monocart-coverage-reports@2.12.11:
dependencies:
acorn: 8.16.0
acorn-loose: 8.5.2
acorn-walk: 8.3.5
commander: 14.0.3
console-grid: 2.2.3
console-grid: 2.2.4
eight-colors: 1.3.3
foreground-child: 3.3.1
foreground-child: 4.0.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
lz-utils: 2.1.0
monocart-locator: 1.0.2
lz-utils: 2.1.1
monocart-locator: 1.0.3
monocart-locator@1.0.2: {}
monocart-locator@1.0.3: {}
mrmime@2.0.1: {}
@@ -18389,6 +18548,8 @@ snapshots:
picomatch@4.0.3: {}
picomatch@4.0.4: {}
pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
@@ -19213,6 +19374,8 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.8.3: {}
shiki@3.23.0:
dependencies:
'@shikijs/core': 3.23.0
@@ -19618,6 +19781,11 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@2.1.0: {}
tinyrainbow@2.0.0: {}
@@ -19984,7 +20152,7 @@ snapshots:
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
unstorage@1.17.4:
unstorage@1.17.4(idb-keyval@6.2.2):
dependencies:
anymatch: 3.1.3
chokidar: 5.0.0
@@ -19994,6 +20162,8 @@ snapshots:
node-fetch-native: 1.6.7
ofetch: 1.5.1
ufo: 1.6.3
optionalDependencies:
idb-keyval: 6.2.2
update-browserslist-db@1.2.2(browserslist@4.28.1):
dependencies:
@@ -20320,6 +20490,48 @@ snapshots:
- tsx
- yaml
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
'@vitest/spy': 4.0.16
'@vitest/utils': 4.0.16
es-module-lexer: 1.7.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.0.3
'@vitest/ui': 4.0.16(vitest@4.0.16)
happy-dom: 20.0.11
jsdom: 27.4.0
transitivePeerDependencies:
- '@vitejs/devtools'
- esbuild
- jiti
- less
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
void-elements@3.1.0: {}
volar-service-css@0.0.70(@volar/language-service@2.4.28):

View File

@@ -3,6 +3,7 @@ packages:
- packages/**
catalog:
'@ai-sdk/openai': ^3.0.53
'@alloc/quick-lru': ^5.2.0
'@astrojs/check': ^0.9.8
'@astrojs/sitemap': ^3.7.1
@@ -54,6 +55,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
'@types/shell-quote': ^1.7.5
'@types/three': ^0.169.0
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -62,6 +64,7 @@ catalog:
'@vueuse/core': ^14.2.0
'@vueuse/integrations': ^14.2.0
'@webgpu/types': ^0.1.66
ai: ^6.0.168
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.13.5
@@ -87,6 +90,7 @@ catalog:
gsap: ^3.14.2
happy-dom: ^20.0.11
husky: ^9.1.7
idb-keyval: ^6.2.2
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
@@ -109,6 +113,7 @@ catalog:
primevue: ^4.2.5
reka-ui: ^2.5.0
rollup-plugin-visualizer: ^6.0.4
shell-quote: ^1.8.3
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0

View File

@@ -0,0 +1,209 @@
#!/bin/bash
set -e
# Deploy frontend preview to Cloudflare Pages and comment on PR
# Usage: ./pr-preview-deploy-and-comment.sh <pr_number> <status>
# Input validation
# Validate PR number is numeric
case "$1" in
''|*[!0-9]*)
echo "Error: PR_NUMBER must be numeric" >&2
exit 1
;;
esac
PR_NUMBER="$1"
# Validate status parameter
STATUS="${2:-completed}"
case "$STATUS" in
starting|completed) ;;
*)
echo "Error: STATUS must be 'starting' or 'completed'" >&2
exit 1
;;
esac
# Required environment variables
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
# Cloudflare variables only required for deployment
if [ "$STATUS" = "completed" ]; then
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
fi
# Configuration
COMMENT_MARKER="<!-- COMFYUI_PREVIEW_DEPLOY -->"
# Resolve wrangler invocation: prefer a locally-available binary, otherwise
# run via pnpm dlx to honour the repo's package-manager policy.
if command -v wrangler > /dev/null 2>&1; then
WRANGLER="wrangler"
else
WRANGLER="pnpm dlx wrangler@^4.0.0"
fi
# Deploy frontend preview, WARN: ensure inputs are sanitized before calling this function
deploy_preview() {
dir="$1"
branch="$2"
[ ! -d "$dir" ] && echo "failed" && return
project="comfy-ui"
echo "Deploying frontend preview to project $project on branch $branch..." >&2
# Try deployment up to 3 times
i=1
while [ $i -le 3 ]; do
echo "Deployment attempt $i of 3..." >&2
# Branch is already sanitized, use it directly
if output=$($WRANGLER pages deploy "$dir" \
--project-name="$project" \
--branch="$branch" 2>&1); then
# Prefer the branch alias URL over the deployment hash URL so the
# link in the PR comment stays stable across redeploys.
branch_url="https://${branch}.${project}.pages.dev"
if echo "$output" | grep -qF "$branch_url"; then
result="$branch_url"
else
# Fall back to first pages.dev URL in wrangler output
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
result="${url:-$branch_url}"
fi
echo "Success! URL: $result" >&2
echo "$result" # Only this goes to stdout for capture
return
else
echo "Deployment failed on attempt $i: $output" >&2
fi
[ $i -lt 3 ] && sleep 10
i=$((i + 1))
done
echo "failed"
}
# Post or update GitHub comment
post_comment() {
body="$1"
temp_file=$(mktemp)
echo "$body" > "$temp_file"
if command -v gh > /dev/null 2>&1; then
# Find existing comment ID
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
if [ -n "$existing" ]; then
# Update specific comment by ID
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
--field body="$(cat "$temp_file")"
else
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
fi
else
echo "GitHub CLI not available, outputting comment:"
cat "$temp_file"
fi
rm -f "$temp_file"
}
# Main execution
if [ "$STATUS" = "starting" ]; then
# Post starting comment
comment="$COMMENT_MARKER
## 🌐 Frontend Preview: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
post_comment "$comment"
elif [ "$STATUS" = "completed" ]; then
# Deploy and post completion comment
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
# Falls back to pr-$PR_NUMBER if BRANCH_NAME is unset
if [ -n "$BRANCH_NAME" ]; then
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
else
cloudflare_branch="pr-$PR_NUMBER"
fi
echo "Looking for frontend build in: $(pwd)/dist"
# Deploy preview if build exists
deployment_url="Not deployed"
if [ -d "dist" ]; then
echo "Found frontend build, deploying..."
url=$(deploy_preview "dist" "$cloudflare_branch")
if [ "$url" != "failed" ] && [ -n "$url" ]; then
deployment_url="[🌐 Open Preview]($url)"
else
deployment_url="Deployment failed"
fi
else
echo "Frontend build not found at dist"
fi
# Get workflow conclusion from environment or default to success
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
WORKFLOW_URL="${WORKFLOW_URL:-}"
# Generate compact header based on conclusion
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
status_icon="✅"
status_text="Built"
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
status_icon="⏭️"
status_text="Skipped"
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
status_icon="🚫"
status_text="Cancelled"
else
status_icon="❌"
status_text="Failed"
fi
# Build compact header with optional preview link
header="## 🌐 Frontend Preview: $status_icon $status_text"
if [ "$deployment_url" != "Not deployed" ] && [ "$deployment_url" != "Deployment failed" ] && [ "$WORKFLOW_CONCLUSION" = "success" ]; then
header="$header$deployment_url"
fi
# Build details section
details="<details>
<summary>Details</summary>
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
**Links**
- [📊 View Workflow Run]($WORKFLOW_URL)"
if [ "$deployment_url" != "Not deployed" ]; then
if [ "$deployment_url" = "Deployment failed" ]; then
details="$details
- ❌ Preview deployment failed"
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
details="$details
- ⚠️ Build failed — $deployment_url"
fi
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
details="$details
- ⏭️ Preview deployment skipped (build did not succeed)"
fi
details="$details
</details>"
comment="$COMMENT_MARKER
$header
$details"
post_comment "$comment"
fi

View File

@@ -1,6 +1,7 @@
<template>
<router-view />
<GlobalDialog />
<AgentRoot />
<BlockUI full-screen :blocked="isLoading" />
</template>
@@ -9,6 +10,7 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, watch } from 'vue'
import AgentRoot from '@/agent/ui/AgentRoot.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'

View File

@@ -0,0 +1,265 @@
import { useLocalStorage } from '@vueuse/core'
import type { ModelMessage } from 'ai'
import { shallowRef } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import type { ToolInvocation } from '../llm/session'
import { streamSession } from '../llm/session'
import { log } from '../services/logger'
import { registerBrowserCommands } from '../shell/commands/browser'
import { registerCodesearchCommands } from '../shell/commands/codesearch'
import { registerComfyCommands } from '../shell/commands/comfy'
import { registerComfyNamespace } from '../shell/commands/comfyNs'
import { registerCoreutils } from '../shell/commands/coreutils'
import { registerExecutionCommands } from '../shell/commands/execution'
import { registerGraphCommands } from '../shell/commands/graph'
import { registerImageCommands } from '../shell/commands/images'
import { registerInstallCommands } from '../shell/commands/install'
import { registerLayoutCommands } from '../shell/commands/layout'
import { registerNodeOpsCommands } from '../shell/commands/nodeOps'
import { registerRegistrySearchCommands } from '../shell/commands/registrySearch'
import { registerSeeCommands } from '../shell/commands/see'
import { registerStateCommands } from '../shell/commands/state'
import { registerSweepCommands } from '../shell/commands/sweep'
import { registerTemplateCommands } from '../shell/commands/templates'
import { registerValidateCommands } from '../shell/commands/validate'
import { registerWorkflowCommands } from '../shell/commands/workflow'
import { CommandRegistryImpl, runScript } from '../shell/runtime'
import type { ExecContext } from '../shell/runtime'
import { collect, emptyIter, stringIter } from '../shell/types'
import type { Command } from '../shell/types'
import { MemoryVFS } from '../shell/vfs/memory'
import { MountedVFS } from '../shell/vfs/mount'
import { UserdataVFS } from '../shell/vfs/userdata'
import type { IngestedAsset } from '../stores/agentStore'
import { useAgentStore } from '../stores/agentStore'
// User's preferred smartest-available model. Override via settings.
const DEFAULT_MODEL = 'gpt-5.5'
const DEFAULT_REASONING_EFFORT = 'high'
const DEFAULT_SYSTEM_APPEND = ''
// Empty by default — the OpenAI SDK falls back to https://api.openai.com.
// User can point this at OpenRouter / a local LLM proxy / a self-hosted
// gateway by overriding via the settings panel.
const DEFAULT_BASE_URL = ''
function buildExecContext(signal: AbortSignal): ExecContext {
const registry = new CommandRegistryImpl()
registerCoreutils(registry)
registerComfyCommands(registry)
registerComfyNamespace(registry)
registerStateCommands(registry)
registerBrowserCommands(registry)
registerCodesearchCommands(registry)
registerExecutionCommands(registry)
registerGraphCommands(registry)
registerImageCommands(registry)
registerInstallCommands(registry)
registerLayoutCommands(registry)
registerNodeOpsCommands(registry)
registerRegistrySearchCommands(registry)
registerSeeCommands(registry)
registerSweepCommands(registry)
registerTemplateCommands(registry)
registerValidateCommands(registry)
registerWorkflowCommands(registry)
// Fallback: any Comfy.* (or other registered) command id can be invoked
// directly as if it were a shell command. Case-insensitive.
registry.addResolver((name) => {
const store = useCommandStore()
const target =
store.getCommand(name) ??
store.commands.find((c) => c.id.toLowerCase() === name.toLowerCase())
if (!target) return undefined
const handler: Command = async () => {
try {
await store.execute(target.id)
return { stdout: stringIter(`ok: ${target.id}\n`), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
return handler
})
const vfs = new MountedVFS({
'/tmp': new MemoryVFS(),
'/workflows': new UserdataVFS('workflows')
})
return {
registry,
vfs,
env: new Map(),
cwd: '/',
signal
}
}
function envApiKey(): string {
const key = import.meta.env.VITE_OPENAI_API_KEY
return typeof key === 'string' ? key : ''
}
export function useAgentSession() {
const store = useAgentStore()
const apiKey = useLocalStorage('Comfy.Agent.OpenAIKey', envApiKey())
const model = useLocalStorage('Comfy.Agent.Model', DEFAULT_MODEL)
const baseURL = useLocalStorage('Comfy.Agent.BaseURL', DEFAULT_BASE_URL)
const reasoningEffort = useLocalStorage(
'Comfy.Agent.ReasoningEffort',
DEFAULT_REASONING_EFFORT
)
const systemPromptAppend = useLocalStorage(
'Comfy.Agent.SystemPromptAppend',
DEFAULT_SYSTEM_APPEND
)
const abortController = shallowRef<AbortController | null>(null)
function buildHistory(): ModelMessage[] {
return store.messages
.filter((m) => m.role !== 'system')
.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.text
}))
}
async function send(text: string, assets: IngestedAsset[]): Promise<void> {
// Abort any in-flight stream from a prior turn so the old callbacks
// stop writing into the wrong placeholder and the new turn starts
// from a clean state.
if (abortController.value) {
abortController.value.abort()
abortController.value = null
store.isStreaming = false
}
const userContent =
assets.length > 0
? `${text}\n\nAttached files:\n${assets.map((a) => `- ${a.path}`).join('\n')}`
: text
store.addMessage({ role: 'user', text, assets })
if (!apiKey.value) {
store.addMessage({
role: 'assistant',
text:
'No API key configured yet. Click the ⚙ settings gear at the top of this panel and paste an OpenAI or OpenRouter API key. ' +
"This agent runs entirely in your browser — your key is stored in localStorage and only sent to the API endpoint you configure (default: OpenAI). It's never seen by the ComfyUI frontend or backend."
})
return
}
const placeholder = store.addMessage({ role: 'assistant', text: '' })
const ac = new AbortController()
abortController.value = ac
store.isStreaming = true
const history = buildHistory()
history[history.length - 1] = { role: 'user', content: userContent }
try {
let streamed = ''
const toolCalls: ToolInvocation[] = []
await streamSession(
{
apiKey: apiKey.value,
model: model.value,
baseURL: baseURL.value || undefined,
reasoningEffort: reasoningEffort.value,
systemPromptAppend: systemPromptAppend.value,
messages: history,
execContext: buildExecContext(ac.signal),
signal: ac.signal
},
(delta) => {
if (ac.signal.aborted) return
streamed += delta
placeholder.text = streamed
},
(inv) => {
if (ac.signal.aborted) return
toolCalls.push(inv)
const summary = `$ ${inv.script}\n${inv.stdout}${inv.stderr ? `\n[stderr] ${inv.stderr}` : ''}`
store.addMessage({
role: 'system',
text: summary,
tool: {
script: inv.script,
stdout: inv.stdout,
stderr: inv.stderr,
exitCode: inv.exitCode
}
})
}
)
// Fallback: model ran tools but didn't speak — surface a minimal
// confirmation so the user isn't staring at tool traces alone.
if (!ac.signal.aborted && !streamed.trim() && toolCalls.length > 0) {
const last = toolCalls[toolCalls.length - 1]
placeholder.text =
last.exitCode === 0
? `(${toolCalls.length} tool call${toolCalls.length > 1 ? 's' : ''} completed)`
: `(tool exited ${last.exitCode})`
}
// Log the FINAL assistant text (agentStore.addMessage only logs the
// empty placeholder at creation time; we need a follow-up entry so
// the server log captures what the user actually saw).
if (!ac.signal.aborted && placeholder.text) {
log({ kind: 'assistant', text: placeholder.text })
}
} catch (err) {
if (!ac.signal.aborted) {
placeholder.text =
'Error: ' + (err instanceof Error ? err.message : String(err))
}
} finally {
// Only clear shared flags if we are still the active stream.
if (abortController.value === ac) {
store.isStreaming = false
abortController.value = null
}
}
}
function stop(): void {
abortController.value?.abort()
}
let cachedCtx: ExecContext | null = null
function buildExecContextOnce(): ExecContext {
if (!cachedCtx) {
cachedCtx = buildExecContext(new AbortController().signal)
}
return cachedCtx
}
async function execShell(
script: string
): Promise<{ stdout: string; stderr?: string; exitCode: number }> {
const ctx = buildExecContextOnce()
const ac = new AbortController()
const res = await runScript(script, { ...ctx, signal: ac.signal })
const stdout = await collect(res.stdout)
return { stdout, stderr: res.stderr, exitCode: res.exitCode }
}
return {
apiKey,
baseURL,
model,
reasoningEffort,
systemPromptAppend,
send,
stop,
execShell,
buildExecContextOnce
}
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest'
import { useAssetIngest } from './useAssetIngest'
function mockFile(name: string, type = 'image/png', size = 10): File {
return new File([new Uint8Array(size)], name, { type })
}
describe('useAssetIngest', () => {
it('uses uploader result path when upload succeeds', async () => {
const uploader = vi.fn().mockResolvedValue('/input/sub/foo.png')
const { ingestFile } = useAssetIngest({ uploader })
const result = await ingestFile(mockFile('foo.png'))
expect(result.remote).toBe(true)
expect(result.asset.path).toBe('/input/sub/foo.png')
expect(result.asset.mime).toBe('image/png')
})
it('falls back to /tmp/pasted when uploader returns null', async () => {
const uploader = vi.fn().mockResolvedValue(null)
const { ingestFile } = useAssetIngest({ uploader })
const result = await ingestFile(mockFile('x.png'))
expect(result.remote).toBe(false)
expect(result.asset.path).toMatch(/^\/tmp\/pasted\//)
})
it('sanitizes filenames', async () => {
const uploader = vi.fn().mockResolvedValue(null)
const { ingestFile } = useAssetIngest({ uploader })
const result = await ingestFile(mockFile('weird name !@#.png'))
expect(result.asset.path).not.toMatch(/[!@#]/)
})
it('creates preview URL for images only', async () => {
const uploader = vi.fn().mockResolvedValue(null)
const { ingestFile } = useAssetIngest({ uploader })
const img = await ingestFile(mockFile('a.png', 'image/png'))
const txt = await ingestFile(mockFile('a.txt', 'text/plain'))
expect(img.asset.previewUrl).toBeDefined()
expect(txt.asset.previewUrl).toBeUndefined()
})
it('ingests multiple files from DataTransfer', async () => {
const uploader = vi.fn().mockResolvedValue('/input/x')
const { ingestFromClipboard } = useAssetIngest({ uploader })
const dt = {
items: [
{ kind: 'file', getAsFile: () => mockFile('a.png') },
{ kind: 'file', getAsFile: () => mockFile('b.png') },
{ kind: 'string', getAsFile: () => null }
],
files: []
} as unknown as DataTransfer
const results = await ingestFromClipboard(dt)
expect(results).toHaveLength(2)
})
it('returns empty list when DataTransfer is null', async () => {
const { ingestFromClipboard } = useAssetIngest({})
expect(await ingestFromClipboard(null)).toEqual([])
})
})

View File

@@ -0,0 +1,92 @@
import { api } from '@/scripts/api'
import type { IngestedAsset } from '../stores/agentStore'
interface IngestResult {
asset: IngestedAsset
remote: boolean
}
function safeName(raw: string): string {
return raw.replace(/[^\w.-]+/g, '_').slice(0, 120) || `pasted_${Date.now()}`
}
function detectExt(mime: string): string {
if (mime === 'image/png') return '.png'
if (mime === 'image/jpeg') return '.jpg'
if (mime === 'image/webp') return '.webp'
if (mime === 'image/gif') return '.gif'
if (mime === 'text/plain') return '.txt'
return ''
}
async function uploadToInput(file: File): Promise<string | null> {
const body = new FormData()
body.append('image', file, file.name)
body.append('type', 'input')
body.append('overwrite', 'false')
try {
const resp = await api.fetchApi('/upload/image', { method: 'POST', body })
if (!resp.ok) return null
const json = (await resp.json()) as { name?: string; subfolder?: string }
if (!json.name) return null
const prefix = json.subfolder ? `${json.subfolder}/` : ''
return `/input/${prefix}${json.name}`
} catch {
return null
}
}
interface AssetIngestOptions {
uploader?: (file: File) => Promise<string | null>
}
export function useAssetIngest(options: AssetIngestOptions = {}) {
const uploader = options.uploader ?? uploadToInput
async function ingestFile(file: File): Promise<IngestResult> {
const remotePath = await uploader(file)
const fallbackName =
file.name && file.name.length > 0
? safeName(file.name)
: safeName('pasted') + detectExt(file.type)
const path = remotePath ?? `/tmp/pasted/${fallbackName}`
const previewUrl = file.type.startsWith('image/')
? URL.createObjectURL(file)
: undefined
return {
asset: {
id: crypto.randomUUID(),
name: fallbackName,
path,
mime: file.type || 'application/octet-stream',
size: file.size,
previewUrl
},
remote: remotePath !== null
}
}
async function ingestFromClipboard(
data: DataTransfer | null
): Promise<IngestResult[]> {
if (!data) return []
const results: IngestResult[] = []
for (const item of Array.from(data.items)) {
if (item.kind !== 'file') continue
const file = item.getAsFile()
if (file) results.push(await ingestFile(file))
}
if (results.length === 0 && data.files && data.files.length > 0) {
for (const file of Array.from(data.files)) {
results.push(await ingestFile(file))
}
}
return results
}
return {
ingestFile,
ingestFromClipboard
}
}

View File

@@ -0,0 +1,70 @@
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
* Drop an uploaded image into the active graph as a LoadImage node.
*
* Given an uploaded filename (the part after `/input/` returned by
* /upload/image), add a LoadImage node at a reasonable position and
* set its widget to the filename. Capture an undo snapshot so Ctrl/Cmd+Z
* reverts the insertion.
*
* Returns the id of the newly created node, or null if the graph was
* not available or the node type is not registered.
*/
export function dropImageAsLoadImageNode(filename: string): number | null {
const canvas = useCanvasStore().canvas
const graph = canvas?.graph as
| { _nodes: { pos: [number, number]; size: [number, number] }[] }
| undefined
if (!canvas || !graph) return null
// Position: to the right of the rightmost existing node, same y as the
// topmost. Feels natural when adding a reference image alongside a
// workflow.
let right = 100
let top = 100
const nodes = graph._nodes ?? []
if (nodes.length > 0) {
right = Math.max(
...nodes.map((n) => (n.pos?.[0] ?? 0) + (n.size?.[0] ?? 200))
)
right += 40
top = Math.min(...nodes.map((n) => n.pos?.[1] ?? 0))
}
// The global LiteGraph instance is installed by the app startup; access
// it via window to avoid tangling imports.
const LG = (
window as unknown as { LiteGraph?: { createNode: (t: string) => unknown } }
).LiteGraph
if (!LG) return null
const node = LG.createNode('LoadImage') as {
id: number
pos: [number, number]
widgets?: {
name?: string
value?: unknown
callback?: (v: unknown) => void
}[]
} | null
if (!node) return null
node.pos = [right, top]
// Set the 'image' widget to the uploaded filename
const widget = node.widgets?.find((w) => w.name === 'image')
if (widget) {
widget.value = filename
widget.callback?.(filename)
}
;(graph as unknown as { add: (n: unknown) => void }).add(node)
canvas.setDirty(true, true)
try {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch {
/* no active workflow */
}
return node.id
}

View File

@@ -0,0 +1,236 @@
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useAgentStore } from '../stores/agentStore'
import { useAgentSession } from './useAgentSession'
const DAEMON_WS = 'ws://127.0.0.1:7437/spa'
const PROTOCOL_VERSION = 1
const SESSION_ID = crypto.randomUUID()
type SpaEventPayload =
| { kind: 'delta'; role: 'assistant'; text: string }
| { kind: 'message'; role: 'user' | 'assistant' | 'system'; text: string }
| {
kind: 'tool'
script: string
stdout: string
stderr?: string
exitCode: number
}
| { kind: 'state'; isStreaming: boolean }
| { kind: 'clear' }
type SpaToDaemon =
| { v: number; type: 'hello'; sessionId: string; title?: string }
| {
v: number
type: 'evalResult'
sessionId: string
opId: string
stdout: string
stderr?: string
exitCode: number
}
| { v: number; type: 'pair-request'; sessionId: string; code: string }
| { v: number; type: 'pong'; sessionId: string }
| { v: number; type: 'event'; payload: SpaEventPayload }
type DaemonToSpa =
| { v: number; type: 'send'; text: string }
| { v: number; type: 'eval'; opId: string; script: string }
| { v: number; type: 'abort' }
| { v: number; type: 'paired'; code: string }
| { v: number; type: 'ping' }
// Singleton state — shared across all callers of useLocalBridge()
const connected = ref(false)
const activePairCode = ref<string | null>(null)
let ws: WebSocket | null = null
let refCount = 0
let sendFn: ((text: string) => void) | null = null
let evalFn:
| ((
opId: string,
script: string
) => Promise<{ stdout: string; stderr?: string; exitCode: number }>)
| null = null
let stopFn: (() => void) | null = null
// Tracks how many messages from the store have been emitted to the daemon.
// Reset to 0 when messages are cleared or a new WS connection opens.
let emittedMsgCount = 0
function sendMsg(msg: SpaToDaemon) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
}
function emitEvent(payload: SpaEventPayload) {
sendMsg({ v: PROTOCOL_VERSION, type: 'event', payload })
}
function connect(
onSend: typeof sendFn,
onEval: typeof evalFn,
onStop: typeof stopFn
) {
sendFn = onSend
evalFn = onEval
stopFn = onStop
if (ws && ws.readyState !== WebSocket.CLOSED) return
ws = new WebSocket(DAEMON_WS)
ws.addEventListener('open', () => {
connected.value = true
emittedMsgCount = 0
sendMsg({
v: PROTOCOL_VERSION,
type: 'hello',
sessionId: SESSION_ID,
title: 'ComfyUI'
})
})
ws.addEventListener('message', async (ev) => {
let msg: DaemonToSpa
try {
msg = JSON.parse(ev.data as string) as DaemonToSpa
} catch {
return
}
if (msg.v !== PROTOCOL_VERSION) return
switch (msg.type) {
case 'ping':
sendMsg({ v: PROTOCOL_VERSION, type: 'pong', sessionId: SESSION_ID })
break
case 'send':
sendFn?.(msg.text)
break
case 'eval': {
const result = (await evalFn?.(msg.opId, msg.script)) ?? {
stdout: '',
exitCode: 0
}
sendMsg({
v: PROTOCOL_VERSION,
type: 'evalResult',
sessionId: SESSION_ID,
opId: msg.opId,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode
})
break
}
case 'abort':
stopFn?.()
break
case 'paired':
if (activePairCode.value === msg.code) activePairCode.value = null
break
}
})
ws.addEventListener('close', () => {
connected.value = false
ws = null
// Reconnect after 3s if still mounted
if (refCount > 0)
setTimeout(() => {
if (refCount > 0) connect(sendFn, evalFn, stopFn)
}, 3000)
})
ws.addEventListener('error', () => {
connected.value = false
})
}
function disconnect() {
refCount--
if (refCount <= 0) {
ws?.close()
ws = null
refCount = 0
connected.value = false
}
}
/** Mount in the root component (AgentRoot) to manage the WS lifecycle. */
export function useLocalBridge() {
const { send, stop, execShell } = useAgentSession()
const agentStore = useAgentStore()
onMounted(() => {
refCount++
connect(
(text) => void send(text, []),
(_opId, script) => execShell(script),
() => stop()
)
})
onUnmounted(disconnect)
// Forward new messages to any subscribed tail/attach clients.
// We track `emittedMsgCount` so reconnects don't re-emit history.
watch(
() => agentStore.messages.length,
(newLen) => {
if (newLen < emittedMsgCount) {
emitEvent({ kind: 'clear' })
emittedMsgCount = 0
return
}
for (let i = emittedMsgCount; i < newLen; i++) {
const msg = agentStore.messages[i]
if (msg.tool) {
emitEvent({ kind: 'tool', ...msg.tool })
} else if (msg.role === 'assistant' && agentStore.isStreaming) {
// Streaming placeholder — wait until done to emit
} else {
emitEvent({
kind: 'message',
role: msg.role as 'user' | 'assistant' | 'system',
text: msg.text
})
}
emittedMsgCount = i + 1
}
}
)
// Emit streaming state transitions and flush the final assistant message.
watch(
() => agentStore.isStreaming,
(isStreaming) => {
emitEvent({ kind: 'state', isStreaming })
if (!isStreaming) {
const msgs = agentStore.messages
const last = msgs[msgs.length - 1]
if (last?.role === 'assistant' && last.text) {
emitEvent({ kind: 'message', role: 'assistant', text: last.text })
emittedMsgCount = msgs.length
}
}
}
)
}
function requestPair(): void {
const code = Math.random().toString(36).slice(2, 8).toUpperCase()
activePairCode.value = code
sendMsg({
v: PROTOCOL_VERSION,
type: 'pair-request',
sessionId: SESSION_ID,
code
})
}
/** Read bridge state from any component — no lifecycle side-effects. */
export function useBridgeStatus() {
return { connected, activePairCode, requestPair }
}

1091
src/agent/llm/session.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
/**
* Agent log — streamed in real time to the Vite dev server's
* /__agent-log endpoint, which appends each JSONL line to
* ./tmp/agent-logs/<YYYY-MM-DD>.jsonl on the repo host.
*
* In production (no dev-plugin endpoint) the POST silently 404s and the
* logger becomes a no-op. To persist in production a later backend
* endpoint (or userdata fallback) would be needed.
*/
interface LogEntry {
t: number
kind: 'user' | 'assistant' | 'system' | 'tool' | 'error' | 'session'
sessionId?: string
text?: string
script?: string
stdout?: string
stderr?: string
exitCode?: number
}
const SESSION_ID =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as Crypto).randomUUID().slice(0, 8)
: Math.random().toString(36).slice(2, 10)
const ENDPOINT = '/__agent-log'
let queue: LogEntry[] = []
let flushTimer: ReturnType<typeof setTimeout> | null = null
let flushing = false
let disabled = false
async function doFlush(): Promise<void> {
if (flushing || queue.length === 0 || disabled) return
flushing = true
const batch = queue.splice(0)
const body = batch.map((e) => JSON.stringify(e)).join('\n') + '\n'
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-ndjson' },
body,
keepalive: true
})
if (res.status === 404) {
// Endpoint doesn't exist (production build). Stop trying.
disabled = true
}
} catch {
// Keep the entries for a retry
queue = batch.concat(queue)
} finally {
flushing = false
if (queue.length > 0) schedule(400)
}
}
function schedule(delay = 250): void {
if (flushTimer || disabled) return
flushTimer = setTimeout(() => {
flushTimer = null
void doFlush()
}, delay)
}
export function log(partial: Omit<LogEntry, 't' | 'sessionId'>): void {
if (disabled) return
queue.push({ t: Date.now(), sessionId: SESSION_ID, ...partial })
schedule()
}
// Best-effort flush on tab close (uses navigator.sendBeacon-style fetch keepalive)
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
void doFlush()
})
// Mark session start
log({ kind: 'session', text: 'session started' })
}

View File

@@ -0,0 +1,210 @@
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { Command, CommandRegistry } from '../types'
import { stringIter } from '../types'
/**
* run-js: Execute arbitrary JavaScript with the ComfyUI app + Pinia stores
* injected as locals, so snippets like
* useCanvasStore().canvas.graph._nodes
* work without any import dance.
*
* Locals bound in the eval scope:
* app, api, document, window,
* useCanvasStore, useCommandStore, useWorkflowStore,
* useMissingModelStore, useExecutionErrorStore, useSettingStore
*/
const INJECT = [
'app',
'api',
'document',
'window',
'useCanvasStore',
'useCommandStore',
'useWorkflowStore',
'useMissingModelStore',
'useExecutionErrorStore',
'useSettingStore',
'useColorPaletteStore'
] as const
/**
* Strip outermost matching quotes (single/double/backtick). The pre-parse
* shortcut for run-js passes the arg verbatim so the LLM often wraps its
* snippet in quotes as it would in a shell — but here those quotes become
* part of the JS source and collapse the whole thing to a string literal
* that evaluates to undefined. Strip them so it works either way.
*/
function stripOuterQuotes(s: string): string {
const trimmed = s.trim()
if (trimmed.length < 2) return trimmed
const first = trimmed[0]
const last = trimmed[trimmed.length - 1]
if ((first === '"' || first === "'" || first === '`') && first === last) {
return trimmed.slice(1, -1)
}
return trimmed
}
const runJs: Command = async (ctx) => {
const code = stripOuterQuotes(ctx.argv.slice(1).join(' '))
if (!code.trim()) {
return {
stdout: stringIter(''),
exitCode: 2,
stderr: 'usage: run-js <js expression or statement>'
}
}
try {
// Intentional: run-js is a DevTools-equivalent eval entry point.
const FnCtor = Function
const fn = new FnCtor(...INJECT, `return (async () => { ${code} })()`) as (
...args: unknown[]
) => Promise<unknown>
const result: unknown = await fn(
app,
api,
document,
window,
useCanvasStore,
useCommandStore,
useWorkflowStore,
useMissingModelStore,
useExecutionErrorStore,
useSettingStore,
useColorPaletteStore
)
const out =
result === undefined ? '' : JSON.stringify(result, null, 2) + '\n'
return { stdout: stringIter(out), exitCode: 0 }
} catch (err) {
return {
stdout: stringIter(''),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* describe <js-expression>
*
* Introspect the shape of any value in the run-js scope (stores, app,
* canvas, nodes …). Returns type, constructor, own-property summary,
* and prototype methods — without dumping huge payloads.
*
* Examples:
* describe useCanvasStore().canvas.graph
* describe app.canvas
* describe useCanvasStore().canvas.graph._nodes[0]
*/
const describeCmd: Command = async (ctx) => {
const expr = stripOuterQuotes(ctx.argv.slice(1).join(' '))
if (!expr) {
return {
stdout: stringIter(''),
exitCode: 2,
stderr: 'usage: describe <expression>'
}
}
try {
const FnCtor = Function
const fn = new FnCtor(
...INJECT,
`return (async () => { return (${expr}) })()`
) as (...args: unknown[]) => Promise<unknown>
const value: unknown = await fn(
app,
api,
document,
window,
useCanvasStore,
useCommandStore,
useWorkflowStore,
useMissingModelStore,
useExecutionErrorStore,
useSettingStore,
useColorPaletteStore
)
return { stdout: stringIter(formatShape(value) + '\n'), exitCode: 0 }
} catch (err) {
return {
stdout: stringIter(''),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
function formatShape(value: unknown): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
const t = typeof value
if (t !== 'object' && t !== 'function') {
return `${t}: ${JSON.stringify(value)}`
}
const ctor =
(value as object).constructor?.name ??
(t === 'function' ? 'Function' : 'object')
const lines: string[] = [`${ctor} (${t})`]
if (Array.isArray(value)) {
lines.push(` length: ${value.length}`)
if (value.length > 0) {
lines.push(` [0]: ${summariseValue(value[0])}`)
if (value.length > 1)
lines.push(` [-1]: ${summariseValue(value[value.length - 1])}`)
}
return lines.join('\n')
}
const obj = value as Record<string, unknown>
const keys = Object.keys(obj).sort()
if (keys.length > 0) {
lines.push(` own properties (${keys.length}):`)
for (const k of keys.slice(0, 40)) {
lines.push(` ${k}: ${summariseValue(obj[k])}`)
}
if (keys.length > 40) lines.push(`${keys.length - 40} more`)
}
// Prototype methods (one level up, shallow)
const proto = Object.getPrototypeOf(value)
if (proto && proto !== Object.prototype && proto !== Function.prototype) {
const protoKeys = Object.getOwnPropertyNames(proto)
.filter((k) => k !== 'constructor')
.sort()
if (protoKeys.length > 0) {
lines.push(` prototype methods (${protoKeys.length}):`)
lines.push(' ' + protoKeys.slice(0, 30).join(', '))
if (protoKeys.length > 30)
lines.push(`${protoKeys.length - 30} more`)
}
}
return lines.join('\n')
}
function summariseValue(v: unknown): string {
if (v === null) return 'null'
if (v === undefined) return 'undefined'
const t = typeof v
if (t === 'function') return 'function'
if (t === 'string') return `string(${(v as string).length})`
if (t === 'number' || t === 'boolean') return `${t} ${String(v)}`
if (Array.isArray(v)) return `Array(${v.length})`
if (t === 'object') {
const ctor = (v as object).constructor?.name ?? 'object'
return ctor
}
return t
}
export function registerBrowserCommands(registry: CommandRegistry): void {
registry.register('run-js', runJs)
registry.register('describe', describeCmd)
}

View File

@@ -0,0 +1,171 @@
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
const API_BASE = 'https://comfy-codesearch.vercel.app'
const DEFAULT_COUNT = 20
interface LineMatch {
preview?: string
lineNumber?: number
}
interface FileMatch {
__typename?: string
repository?: { name?: string }
file?: { path?: string }
lineMatches?: LineMatch[]
}
interface RepoMatch {
__typename?: string
name?: string
}
interface SearchResponse {
data?: {
search?: {
stats?: { approximateResultCount?: string }
results?: {
matchCount?: number
elapsedMilliseconds?: number
results?: (FileMatch | RepoMatch)[]
}
}
}
}
async function csFetch(
endpoint: 'code' | 'repo',
query: string
): Promise<SearchResponse> {
const url = `${API_BASE}/api/search/${endpoint}?query=${encodeURIComponent(query)}`
const res = await fetch(url)
if (!res.ok) {
throw new Error(
`comfy-codesearch ${endpoint}: API error ${res.status} ${res.statusText}`
)
}
return (await res.json()) as SearchResponse
}
function formatCodeResults(json: SearchResponse, query: string): string {
const r = json.data?.search?.results
const stats = json.data?.search?.stats
const hits = (r?.results ?? []) as FileMatch[]
if (hits.length === 0) {
return `no matches for "${query}" across the public ComfyUI codebase.\n`
}
const repos = new Set<string>()
for (const h of hits) if (h.repository?.name) repos.add(h.repository.name)
const header =
`${r?.matchCount ?? hits.length} match(es) in ${repos.size} repo(s)` +
(stats?.approximateResultCount
? ` (~${stats.approximateResultCount} total)`
: '') +
(r?.elapsedMilliseconds !== undefined
? `, took ${r.elapsedMilliseconds}ms`
: '') +
':\n'
const lines: string[] = []
for (const h of hits) {
const repo = h.repository?.name ?? '?'
const path = h.file?.path ?? '?'
const lms = h.lineMatches ?? []
if (lms.length === 0) {
lines.push(` ${repo} ${path}`)
continue
}
for (const lm of lms) {
const ln = lm.lineNumber ?? '?'
const preview = (lm.preview ?? '').replace(/\s+$/, '')
lines.push(` ${repo} ${path}:${ln}\n ${preview}`)
}
}
return header + lines.join('\n') + '\n'
}
function formatRepoResults(json: SearchResponse, query: string): string {
const r = json.data?.search?.results
const hits = (r?.results ?? []) as RepoMatch[]
if (hits.length === 0) {
return `no repos match "${query}" in the public ComfyUI codebase index.\n`
}
const lines = hits.map((h) => ' ' + (h.name ?? '?'))
return `${hits.length} repo(s) match "${query}":\n` + lines.join('\n') + '\n'
}
/**
* comfy-codesearch <query> [--repo] [--count N]
*
* Search source code (or repo names) across the WHOLE public ComfyUI
* community via cs.comfy.org. Use this to find node-class definitions,
* extension APIs, or example code in repos that aren't yet published to
* the registry — `node-search-registry` only sees published packs, but
* many custom nodes live as plain GitHub repos.
*
* Query syntax is Sourcegraph-flavored:
* - plain text fuzzy substring across all indexed repos
* - `repo:Comfy-Org/ComfyUI foo` scope to a specific repo
* - `count:50 foo` cap result count (otherwise --count is used)
* - `class\\s+Wacom` regex
*
* Examples:
* comfy-codesearch "NODE_CLASS_MAPPINGS.*[Ww]acom"
* comfy-codesearch --repo wacom
* comfy-codesearch "repo:Comfy-Org/ComfyUI last_node_id" --count 5
*/
const comfyCodesearch: Command = async (ctx) => {
const args = ctx.argv.slice(1)
let mode: 'code' | 'repo' = 'code'
let count = DEFAULT_COUNT
const queryParts: string[] = []
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '--repo' || a === '-r') {
mode = 'repo'
} else if (a === '--count' || a === '-c') {
const next = args[i + 1]
const n = Number(next)
if (!Number.isFinite(n) || n <= 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `comfy-codesearch: --count needs a positive number, got "${next ?? ''}"`
}
}
count = n
i++
} else {
queryParts.push(a)
}
}
const query = queryParts.join(' ').trim()
if (!query) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'usage: comfy-codesearch <query> [--repo] [--count N]\n' +
' (searches the whole public ComfyUI community via cs.comfy.org)'
}
}
let effectiveQuery = query
if (mode === 'code' && !/\bcount:\d+/.test(query)) {
effectiveQuery = `count:${count} ${query}`
}
try {
const json = await csFetch(mode, effectiveQuery)
const text =
mode === 'code'
? formatCodeResults(json, query)
: formatRepoResults(json, query)
return { stdout: stringIter(text), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
export function registerCodesearchCommands(registry: CommandRegistry): void {
registry.register('comfy-codesearch', comfyCodesearch)
}

View File

@@ -0,0 +1,60 @@
import { useCommandStore } from '@/stores/commandStore'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
const cmd: Command = async (ctx) => {
const id = ctx.argv[1]
if (!id) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: cmd <command-id> [args...]'
}
}
const store = useCommandStore()
const target = store.getCommand(id)
if (!target) {
return {
stdout: emptyIter(),
exitCode: 127,
stderr: `cmd: unknown command id: ${id}`
}
}
try {
await store.execute(id)
return { stdout: stringIter(`ok: ${id}\n`), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
const cmdList: Command = async (ctx) => {
const store = useCommandStore()
const patterns = ctx.argv.slice(1).filter(Boolean)
const ids = store.commands
.map((c) => c.id)
.filter((id) => {
if (patterns.length === 0) return true
const lc = id.toLowerCase()
return patterns.some((p) => {
try {
return new RegExp(p, 'i').test(id)
} catch {
return lc.includes(p.toLowerCase())
}
})
})
.sort()
const out = ids.length === 0 ? '(no matches)\n' : ids.join('\n') + '\n'
return { stdout: stringIter(out), exitCode: 0 }
}
export function registerComfyCommands(registry: CommandRegistry): void {
registry.register('cmd', cmd)
registry.register('cmd-list', cmdList)
}

View File

@@ -0,0 +1,222 @@
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import type { CmdContext, Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
/**
* Namespace dispatcher for Comfy.* UI commands.
*
* Usage:
* comfy list top-level namespaces (Canvas, Workflow…)
* comfy --help same
* comfy canvas list commands under Canvas
* comfy canvas --help same
* comfy canvas fitview execute Comfy.Canvas.FitView
* comfy canvas fitview --help show description / shortcut / version
*
* Names match case-insensitive. Dot form (Comfy.Canvas.FitView) also works —
* that's routed via the registry resolver, this command just gives the
* nicer space-separated git-like ergonomics and --help at every level.
*/
interface CommandEntry {
id: string
label: string
tooltip?: string
versionAdded?: string
}
function allCommands(): CommandEntry[] {
return useCommandStore().commands.map((c) => ({
id: c.id,
label: c.label ?? c.id,
tooltip: c.tooltip,
versionAdded: c.versionAdded
}))
}
function filterByPath(
cmds: CommandEntry[],
pathParts: string[]
): {
exact: CommandEntry | null
childNamesAtNextLevel: string[]
descendants: CommandEntry[]
} {
const lower = pathParts.map((p) => p.toLowerCase())
const descendants = cmds.filter((c) => {
const parts = c.id.split('.').map((p) => p.toLowerCase())
if (parts.length <= lower.length) return false
for (let i = 0; i < lower.length; i++) {
if (parts[i + 1] !== lower[i]) return false
}
return true
})
const exact =
cmds.find(
(c) => c.id.toLowerCase() === ['comfy', ...lower].join('.').toLowerCase()
) ?? null
const nextLevelSet = new Set<string>()
for (const c of descendants) {
const parts = c.id.split('.')
const nextPart = parts[lower.length + 1]
if (nextPart) nextLevelSet.add(nextPart)
}
return {
exact,
childNamesAtNextLevel: [...nextLevelSet].sort(),
descendants
}
}
function formatHelp(
path: string[],
entries: CommandEntry[],
children: string[]
): string {
const header = path.length === 0 ? 'comfy' : 'comfy ' + path.join(' ')
const lines: string[] = []
lines.push(`\x1b[1m${header}\x1b[0m — ComfyUI command namespace`)
lines.push('')
if (children.length > 0) {
lines.push('namespaces / subcommands:')
for (const name of children) {
// count how many commands are at or under this child
const prefix = 'Comfy.' + [...path, name].join('.').toLowerCase()
const count = entries.filter((c) =>
c.id.toLowerCase().startsWith(prefix)
).length
const suffix = count > 1 ? ` (${count} commands)` : ''
lines.push(` ${name.toLowerCase()}${suffix}`)
}
lines.push('')
}
lines.push(
'tip: append --help at any level for details, or run the leaf to execute.'
)
return lines.join('\n') + '\n'
}
function formatLeafHelp(entry: CommandEntry): string {
const lines: string[] = []
lines.push(`\x1b[1m${entry.id}\x1b[0m`)
if (entry.label && entry.label !== entry.id)
lines.push(` label: ${entry.label}`)
if (entry.tooltip) lines.push(` tooltip: ${entry.tooltip}`)
const kb = useKeybindingStore().getKeybindingByCommandId(entry.id)
if (kb?.combo) {
const keys = [
kb.combo.ctrl && 'Ctrl',
kb.combo.alt && 'Alt',
kb.combo.shift && 'Shift',
kb.combo.key
]
.filter(Boolean)
.join('+')
lines.push(` shortcut: ${keys}`)
}
if (entry.versionAdded) lines.push(` added: v${entry.versionAdded}`)
lines.push('')
lines.push(
'invocation: run without --help to execute, e.g. comfy ' +
entry.id
.replace(/^Comfy\./, '')
.split('.')
.join(' ')
.toLowerCase()
)
lines.push(' or: ' + entry.id)
return lines.join('\n') + '\n'
}
async function executeLeaf(
id: string,
args: string[] = []
): Promise<{
stdout: AsyncIterable<string>
exitCode: number
stderr?: string
}> {
const store = useCommandStore()
try {
await store.execute(id, { metadata: { args } })
const suffix = args.length > 0 ? ` (args: ${args.join(' ')})` : ''
return { stdout: stringIter(`ok: ${id}${suffix}\n`), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* Progressive leaf resolution: walk the path from longest to shortest,
* returning the first prefix that resolves to an exact registered command.
* The remaining trailing tokens become passthrough args (delivered via
* `metadata.args` to the command function).
*/
function resolveLongestLeaf(
cmds: CommandEntry[],
pathParts: string[]
): { leaf: CommandEntry; args: string[] } | null {
for (let n = pathParts.length; n >= 1; n--) {
const prefix = pathParts.slice(0, n)
const { exact } = filterByPath(cmds, prefix)
if (exact) return { leaf: exact, args: pathParts.slice(n) }
}
return null
}
const comfyCmd: Command = async (ctx: CmdContext) => {
const rawArgs = ctx.argv.slice(1)
const wantsHelp =
rawArgs[rawArgs.length - 1] === '--help' ||
rawArgs[rawArgs.length - 1] === '-h'
const pathParts = rawArgs.filter((a) => a !== '--help' && a !== '-h')
const cmds = allCommands()
const { exact, childNamesAtNextLevel, descendants } = filterByPath(
cmds,
pathParts
)
// Leaf command + --help → show that command's detail
if (exact && wantsHelp) {
return { stdout: stringIter(formatLeafHelp(exact)), exitCode: 0 }
}
// Leaf command (no --help) → execute
if (exact && childNamesAtNextLevel.length === 0) {
return executeLeaf(exact.id)
}
// If there's an exact match AND children, ambiguous: prefer execute when
// no more args, else treat as a namespace (shouldn't really happen in
// the current ComfyUI namespace but guard anyway).
if (exact && pathParts.length > 0 && !wantsHelp) {
return executeLeaf(exact.id)
}
// Not a leaf — try progressive resolution: maybe the first N tokens
// name a leaf and the rest are passthrough args (e.g.
// `comfy saveworkflowas bbb` → Comfy.SaveWorkflowAs with args=['bbb']).
if (pathParts.length > 0 && descendants.length === 0 && !exact) {
const resolved = resolveLongestLeaf(cmds, pathParts)
if (resolved) return executeLeaf(resolved.leaf.id, resolved.args)
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `comfy: no command or namespace '${pathParts.join(' ')}'`
}
}
return {
stdout: stringIter(formatHelp(pathParts, cmds, childNamesAtNextLevel)),
exitCode: 0
}
}
export function registerComfyNamespace(registry: CommandRegistry): void {
registry.register('comfy', comfyCmd)
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { CommandRegistryImpl } from '../runtime'
import type { CmdContext } from '../types'
import { collect, emptyIter, stringIter } from '../types'
import { MemoryVFS } from '../vfs/memory'
import { coreutils, registerCoreutils } from './coreutils'
function baseCtx(
argv: string[],
stdin: AsyncIterable<string> = emptyIter(),
vfs = new MemoryVFS()
): CmdContext {
return {
argv,
stdin,
env: new Map(),
cwd: '/',
vfs,
signal: new AbortController().signal
}
}
describe('coreutils', () => {
it('echo joins args with space', async () => {
const r = await coreutils.echo(baseCtx(['echo', 'hello', 'world']))
expect(await collect(r.stdout)).toBe('hello world\n')
})
it('echo -n omits newline', async () => {
const r = await coreutils.echo(baseCtx(['echo', '-n', 'hi']))
expect(await collect(r.stdout)).toBe('hi')
})
it('cat reads file', async () => {
const fs = new MemoryVFS()
await fs.write('/f', 'contents')
const r = await coreutils.cat(baseCtx(['cat', '/f'], emptyIter(), fs))
expect(await collect(r.stdout)).toBe('contents')
})
it('cat passes through stdin with no args', async () => {
const r = await coreutils.cat(baseCtx(['cat'], stringIter('passed\n')))
expect(await collect(r.stdout)).toBe('passed\n')
})
it('ls lists sorted entries', async () => {
const fs = new MemoryVFS()
await fs.write('/b', '')
await fs.write('/a', '')
await fs.write('/sub/x', '')
const r = await coreutils.ls(baseCtx(['ls', '/'], emptyIter(), fs))
expect(await collect(r.stdout)).toBe('a\nb\nsub/\n')
})
it('pwd emits cwd', async () => {
const r = await coreutils.pwd(baseCtx(['pwd']))
expect(await collect(r.stdout)).toBe('/\n')
})
it('wc counts lines, words, bytes', async () => {
const r = await coreutils.wc(baseCtx(['wc'], stringIter('a\nb\nc\n')))
expect(await collect(r.stdout)).toBe('3 3 6\n')
})
it('head -n 2 keeps first 2', async () => {
const r = await coreutils.head(
baseCtx(['head', '-n', '2'], stringIter('1\n2\n3\n4\n'))
)
expect(await collect(r.stdout)).toBe('1\n2\n')
})
it('tail -n 2 keeps last 2', async () => {
const r = await coreutils.tail(
baseCtx(['tail', '-n', '2'], stringIter('1\n2\n3\n4\n'))
)
expect(await collect(r.stdout)).toBe('3\n4\n')
})
it('grep filters', async () => {
const r = await coreutils.grep(
baseCtx(['grep', 'foo'], stringIter('foo\nbar\nfood\n'))
)
expect(await collect(r.stdout)).toBe('foo\nfood\n')
})
it('true exits 0, false exits 1', async () => {
expect((await coreutils.true(baseCtx(['true']))).exitCode).toBe(0)
expect((await coreutils.false(baseCtx(['false']))).exitCode).toBe(1)
})
it('seq N counts 1..N inclusive', async () => {
const r = await coreutils.seq(baseCtx(['seq', '3']))
expect(await collect(r.stdout)).toBe('1\n2\n3\n')
})
it('seq A B counts A..B inclusive', async () => {
const r = await coreutils.seq(baseCtx(['seq', '5', '8']))
expect(await collect(r.stdout)).toBe('5\n6\n7\n8\n')
})
it('seq A STEP B supports custom step', async () => {
const r = await coreutils.seq(baseCtx(['seq', '10', '5', '25']))
expect(await collect(r.stdout)).toBe('10\n15\n20\n25\n')
})
it('seq supports negative step', async () => {
const r = await coreutils.seq(baseCtx(['seq', '3', '-1', '1']))
expect(await collect(r.stdout)).toBe('3\n2\n1\n')
})
it('registerCoreutils registers all commands', () => {
const reg = new CommandRegistryImpl()
registerCoreutils(reg)
expect(reg.list()).toEqual(
[
'cat',
'echo',
'false',
'grep',
'head',
'ls',
'pwd',
'seq',
'tail',
'true',
'wc'
].sort()
)
})
})

View File

@@ -0,0 +1,184 @@
import type { CmdContext, CmdResult, Command, CommandRegistry } from '../types'
import { collect, emptyIter, lines, stringIter } from '../types'
function ok(stdout: AsyncIterable<string>, exitCode = 0): CmdResult {
return { stdout, exitCode }
}
function err(message: string, exitCode = 2): CmdResult {
return { stdout: emptyIter(), exitCode, stderr: message }
}
const echo: Command = async (ctx) => {
const args = ctx.argv.slice(1)
let newline = true
if (args[0] === '-n') {
newline = false
args.shift()
}
const text = args.join(' ') + (newline ? '\n' : '')
return ok(stringIter(text))
}
const cat: Command = async (ctx) => {
const paths = ctx.argv.slice(1)
if (paths.length === 0) return ok(ctx.stdin)
async function* gen(): AsyncIterable<string> {
for (const p of paths) {
yield await ctx.vfs.read(p)
}
}
return ok(gen())
}
const ls: Command = async (ctx) => {
const path = ctx.argv[1] ?? ctx.cwd
const entries = await ctx.vfs.list(path)
const out =
entries.map((e) => (e.type === 'dir' ? e.name + '/' : e.name)).join('\n') +
(entries.length > 0 ? '\n' : '')
return ok(stringIter(out))
}
const pwd: Command = async (ctx) => ok(stringIter(ctx.cwd + '\n'))
const wc: Command = async (ctx) => {
const data = await collect(ctx.stdin)
const bytes = data.length
const lineCount =
data === '' ? 0 : data.split('\n').length - (data.endsWith('\n') ? 1 : 0)
const words = data.split(/\s+/).filter((w) => w.length > 0).length
return ok(stringIter(`${lineCount} ${words} ${bytes}\n`))
}
function parseNFlag(
argv: string[],
defaultN: number
): { n: number; rest: string[] } {
const rest = argv.slice(1)
let n = defaultN
if (rest[0] === '-n') {
const parsed = Number(rest[1])
if (!Number.isFinite(parsed)) throw new Error('invalid -n value')
n = parsed
rest.splice(0, 2)
} else if (rest[0]?.startsWith('-n')) {
const parsed = Number(rest[0].slice(2))
if (!Number.isFinite(parsed)) throw new Error('invalid -n value')
n = parsed
rest.shift()
}
return { n, rest }
}
const head: Command = async (ctx) => {
let n: number
try {
;({ n } = parseNFlag(ctx.argv, 10))
} catch (e) {
return err('usage: head [-n N]')
}
async function* gen(): AsyncIterable<string> {
let i = 0
for await (const line of lines(ctx.stdin)) {
if (i >= n) break
yield line + '\n'
i++
}
}
return ok(gen())
}
const tail: Command = async (ctx) => {
let n: number
try {
;({ n } = parseNFlag(ctx.argv, 10))
} catch (e) {
return err('usage: tail [-n N]')
}
const buf: string[] = []
for await (const line of lines(ctx.stdin)) {
buf.push(line)
if (buf.length > n) buf.shift()
}
const out = buf.length > 0 ? buf.join('\n') + '\n' : ''
return ok(stringIter(out))
}
const grep: Command = async (ctx) => {
const pattern = ctx.argv[1]
if (!pattern) return err('usage: grep <pattern>')
const re = new RegExp(pattern)
// POSIX grep returns 1 when nothing matched. To honour that we have to
// drain stdin eagerly — exit codes are set on the Command return, but a
// generator can't change them after the fact. The agent relies on this
// for `grep ... && ...` / `grep ... || ...` flows; without the right
// exit code the LLM would conclude evidence existed when stdout was
// actually empty.
let matched = false
let out = ''
for await (const line of lines(ctx.stdin)) {
if (re.test(line)) {
out += line + '\n'
matched = true
}
}
return ok(stringIter(out), matched ? 0 : 1)
}
const trueCmd: Command = async () => ok(emptyIter(), 0)
const falseCmd: Command = async () => ok(emptyIter(), 1)
const seqCmd: Command = async (ctx) => {
const args = ctx.argv.slice(1).map(Number)
if (args.some((n) => !Number.isFinite(n))) {
return err('usage: seq [start] [step] end')
}
let start = 1,
step = 1,
end: number
if (args.length === 1) end = args[0]
else if (args.length === 2) {
start = args[0]
end = args[1]
} else if (args.length === 3) {
start = args[0]
step = args[1]
end = args[2]
} else {
return err('usage: seq [start] [step] end')
}
if (step === 0) return err('step must not be zero')
const out: string[] = []
if (step > 0) for (let i = start; i <= end; i += step) out.push(String(i))
else for (let i = start; i >= end; i += step) out.push(String(i))
return ok(stringIter(out.join('\n') + (out.length ? '\n' : '')))
}
export function registerCoreutils(registry: CommandRegistry): void {
registry.register('echo', echo)
registry.register('cat', cat)
registry.register('ls', ls)
registry.register('pwd', pwd)
registry.register('wc', wc)
registry.register('head', head)
registry.register('tail', tail)
registry.register('grep', grep)
registry.register('true', trueCmd)
registry.register('false', falseCmd)
registry.register('seq', seqCmd)
}
export const coreutils = {
echo,
cat,
ls,
pwd,
wc,
head,
tail,
grep,
true: trueCmd,
false: falseCmd,
seq: seqCmd
} satisfies Record<string, (ctx: CmdContext) => Promise<CmdResult>>

View File

@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/api', () => {
class FakeApi extends EventTarget {
getQueue = vi.fn()
getHistory = vi.fn()
getJobDetail = vi.fn()
}
return { api: new FakeApi() }
})
import { api } from '@/scripts/api'
import { CommandRegistryImpl } from '../runtime'
import type { CmdContext } from '../types'
import { collect, emptyIter } from '../types'
import { MemoryVFS } from '../vfs/memory'
import { registerExecutionCommands } from './execution'
const mocked = vi.mocked(api)
function ctx(argv: string[], signal?: AbortSignal): CmdContext {
return {
argv,
stdin: emptyIter(),
env: new Map(),
cwd: '/',
vfs: new MemoryVFS(),
signal: signal ?? new AbortController().signal
}
}
describe('execution commands', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('queue-status lists running and pending', async () => {
mocked.getQueue.mockResolvedValue({
Running: [
{ id: 'r1', status: 'in_progress', create_time: 1, priority: 0 }
],
Pending: [
{ id: 'p1', status: 'pending', create_time: 2, priority: 0 },
{ id: 'p2', status: 'pending', create_time: 3, priority: 0 }
]
})
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('queue-status')!(ctx(['queue-status']))
const out = await collect(res.stdout)
expect(out).toContain('running: 1')
expect(out).toContain('pending: 2')
expect(out).toContain('r1')
expect(out).toContain('p2')
})
it('history --last=2 returns 2 rows', async () => {
mocked.getHistory.mockResolvedValue([
{
id: 'a',
status: 'completed',
create_time: 1,
priority: 0
},
{
id: 'b',
status: 'completed',
create_time: 2,
priority: 0
}
])
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('history')!(ctx(['history', '--last=2']))
expect(mocked.getHistory).toHaveBeenCalledWith(2)
const out = await collect(res.stdout)
expect(out.split('\n').filter(Boolean)).toHaveLength(2)
})
it('wait-queue returns immediately when idle', async () => {
mocked.getQueue.mockResolvedValue({ Running: [], Pending: [] })
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('wait-queue')!(
ctx(['wait-queue', '--timeout=1', '--poll=1'])
)
expect(res.exitCode).toBe(0)
expect(await collect(res.stdout)).toMatch(/queue idle/)
})
it('wait-queue respects aborted signal', async () => {
mocked.getQueue.mockResolvedValue({
Running: [
{ id: 'r', status: 'in_progress', create_time: 1, priority: 0 }
],
Pending: []
})
const ac = new AbortController()
ac.abort()
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('wait-queue')!(
ctx(['wait-queue', '--timeout=1', '--poll=1'], ac.signal)
)
expect(res.exitCode).toBe(130)
})
it('latest-output returns no history when empty', async () => {
mocked.getHistory.mockResolvedValue([])
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('latest-output')!(ctx(['latest-output']))
expect(res.exitCode).toBe(1)
expect(res.stderr).toContain('no history')
})
it('latest-output emits view URLs for image outputs', async () => {
mocked.getHistory.mockResolvedValue([
{
id: 'job-1',
status: 'completed',
create_time: 1,
priority: 0
}
])
mocked.getJobDetail.mockResolvedValue({
id: 'job-1',
status: 'completed',
create_time: 1,
priority: 0,
outputs: {
'9': {
images: [{ filename: 'out.png', subfolder: '', type: 'output' }]
}
}
})
const r = new CommandRegistryImpl()
registerExecutionCommands(r)
const res = await r.get('latest-output')!(ctx(['latest-output']))
const out = await collect(res.stdout)
expect(out).toContain('job-1')
expect(out).toContain('/view?filename=out.png')
})
})

View File

@@ -0,0 +1,120 @@
import { api } from '@/scripts/api'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
interface RawJob {
id: string
status: string
execution_error?: unknown
outputs_count?: number | null
workflow_id?: string | null
}
function jobState(j: RawJob): string {
if (j.execution_error) return 'error'
return j.status || 'unknown'
}
function fmtJob(j: RawJob): string {
return `${jobState(j)}\t${j.id}\t${j.workflow_id ?? ''}`
}
const queueStatus: Command = async () => {
const { Running, Pending } = await api.getQueue()
const lines: string[] = []
lines.push(`running: ${Running.length}`)
for (const j of Running) lines.push(' ' + fmtJob(j as unknown as RawJob))
lines.push(`pending: ${Pending.length}`)
for (const j of Pending) lines.push(' ' + fmtJob(j as unknown as RawJob))
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
const historyCmd: Command = async (ctx) => {
const arg = ctx.argv.find((a) => a.startsWith('--last='))
const last = arg ? Number(arg.slice(7)) : 10
const max = Number.isFinite(last) && last > 0 ? Math.min(last, 200) : 10
const items = await api.getHistory(max)
const lines = items.map((j) => fmtJob(j as unknown as RawJob))
return {
stdout: stringIter(lines.join('\n') + (lines.length ? '\n' : '')),
exitCode: 0
}
}
const waitQueue: Command = async (ctx) => {
const timeoutArg = ctx.argv.find((a) => a.startsWith('--timeout='))
const timeoutMs = timeoutArg ? Number(timeoutArg.slice(10)) * 1000 : 300_000
const pollArg = ctx.argv.find((a) => a.startsWith('--poll='))
const pollMs = pollArg ? Number(pollArg.slice(7)) * 1000 : 1000
const started = Date.now()
while (Date.now() - started < timeoutMs) {
if (ctx.signal.aborted) {
return { stdout: emptyIter(), exitCode: 130, stderr: 'aborted' }
}
const { Running, Pending } = await api.getQueue()
if (Running.length === 0 && Pending.length === 0) {
const elapsed = ((Date.now() - started) / 1000).toFixed(1)
return {
stdout: stringIter(`queue idle after ${elapsed}s\n`),
exitCode: 0
}
}
await new Promise((r) => setTimeout(r, pollMs))
}
return {
stdout: emptyIter(),
exitCode: 124,
stderr: `timed out after ${timeoutMs / 1000}s`
}
}
const latestOutput: Command = async () => {
const items = await api.getHistory(1)
if (items.length === 0) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no history' }
}
const job = items[0] as unknown as RawJob
const detail = await api.getJobDetail(job.id)
const outputs = detail?.outputs ?? {}
const previews: string[] = []
for (const [nodeId, out] of Object.entries(outputs)) {
const images = (
out as {
images?: { filename?: string; subfolder?: string; type?: string }[]
}
).images
if (!images) continue
for (const img of images) {
if (!img.filename) continue
const sub = img.subfolder
? `&subfolder=${encodeURIComponent(img.subfolder)}`
: ''
const type = img.type ? `&type=${encodeURIComponent(img.type)}` : ''
previews.push(
`node=${nodeId}\t/view?filename=${encodeURIComponent(img.filename)}${sub}${type}`
)
}
}
const state = jobState(job)
if (previews.length === 0) {
return {
stdout: stringIter(`job ${job.id}\t${state}\tno images\n`),
exitCode: 0
}
}
return {
stdout: stringIter(
[`job: ${job.id}`, `state: ${state}`, ...previews].join('\n') + '\n'
),
exitCode: 0
}
}
export function registerExecutionCommands(registry: CommandRegistry): void {
registry.register('queue-status', queueStatus)
registry.register('history', historyCmd)
registry.register('wait-queue', waitQueue)
registry.register('latest-output', latestOutput)
}

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const canvasRef = { value: null as unknown }
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get canvas() {
return canvasRef.value
},
set canvas(v: unknown) {
canvasRef.value = v
}
})
}))
import { CommandRegistryImpl } from '../runtime'
import type { CmdContext } from '../types'
import { collect, emptyIter } from '../types'
import { MemoryVFS } from '../vfs/memory'
import { registerGraphCommands } from './graph'
function ctx(argv: string[]): CmdContext {
return {
argv,
stdin: emptyIter(),
env: new Map(),
cwd: '/',
vfs: new MemoryVFS(),
signal: new AbortController().signal
}
}
function setGraph(nodes: unknown[]) {
canvasRef.value = { graph: { _nodes: nodes } }
}
describe('graph command', () => {
beforeEach(() => {
canvasRef.value = null
})
it('errors when no active graph', async () => {
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'summary']))
expect(res.exitCode).toBe(1)
expect(res.stderr).toContain('no active graph')
})
it('summary lists type counts', async () => {
setGraph([
{ id: 1, comfyClass: 'KSampler' },
{ id: 2, comfyClass: 'KSampler' },
{ id: 3, comfyClass: 'CheckpointLoaderSimple' }
])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'summary']))
const out = await collect(res.stdout)
expect(out).toContain('nodes: 3')
expect(out).toContain('2\tKSampler')
expect(out).toContain('1\tCheckpointLoaderSimple')
})
it('nodes with regex filters by type', async () => {
setGraph([
{ id: 1, comfyClass: 'KSampler', title: 'main' },
{ id: 2, comfyClass: 'CLIPTextEncode' }
])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'nodes', 'KSampler']))
const out = await collect(res.stdout)
expect(out).toContain('1\tKSampler\tmain')
expect(out).not.toContain('CLIPTextEncode')
})
it('node <id> returns JSON summary', async () => {
setGraph([
{
id: 5,
comfyClass: 'KSampler',
pos: [10, 20],
widgets: [{ name: 'seed', value: 42, type: 'int' }],
inputs: [{ name: 'model', type: 'MODEL', link: null }],
outputs: [{ name: 'LATENT', type: 'LATENT', links: [1, 2] }]
}
])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'node', '5']))
const out = await collect(res.stdout)
const parsed = JSON.parse(out)
expect(parsed.id).toBe(5)
expect(parsed.type).toBe('KSampler')
expect(parsed.widgets[0]).toEqual({ name: 'seed', value: 42, type: 'int' })
expect(parsed.outputs[0].linkCount).toBe(2)
})
it('node <id> errors on missing node', async () => {
setGraph([{ id: 1, comfyClass: 'X' }])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('graph')!(ctx(['graph', 'node', '99']))
expect(res.exitCode).toBe(1)
expect(res.stderr).toContain('no node 99')
})
it('set-widget mutates value and fires callback', async () => {
const cb = vi.fn()
setGraph([
{
id: 3,
comfyClass: 'KSampler',
widgets: [{ name: 'cfg', type: 'FLOAT', value: 8, callback: cb }]
}
])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('set-widget')!(
ctx(['set-widget', '3', 'cfg', '6.5'])
)
expect(res.exitCode).toBe(0)
expect(cb).toHaveBeenCalledWith(6.5)
expect(await collect(res.stdout)).toContain('6.5')
})
it('set-widget errors on missing widget', async () => {
setGraph([{ id: 3, comfyClass: 'KSampler', widgets: [] }])
const r = new CommandRegistryImpl()
registerGraphCommands(r)
const res = await r.get('set-widget')!(
ctx(['set-widget', '3', 'nope', '1'])
)
expect(res.exitCode).toBe(1)
expect(res.stderr).toContain('no widget')
})
})

View File

@@ -0,0 +1,197 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
interface WidgetSummary {
name: string
value: unknown
type?: string
}
interface NodeSummary {
id: number | string
type: string
title?: string
pos?: [number, number]
mode?: number
widgets?: WidgetSummary[]
inputs?: { name: string; type: string; linkId?: number | null }[]
outputs?: { name: string; type: string; linkCount: number }[]
}
function getGraph() {
const canvas = useCanvasStore().canvas
return canvas?.graph ?? null
}
function summarizeNode(node: unknown): NodeSummary {
const n = node as {
id: number
type?: string
comfyClass?: string
title?: string
pos?: [number, number]
mode?: number
widgets?: { name?: string; value?: unknown; type?: string }[]
inputs?: { name?: string; type?: string; link?: number | null }[]
outputs?: {
name?: string
type?: string
links?: (number | null)[] | null
}[]
}
return {
id: n.id,
type: n.comfyClass ?? n.type ?? 'Unknown',
title: n.title,
pos: n.pos,
mode: n.mode,
widgets: n.widgets?.map((w) => ({
name: w.name ?? '',
value: w.value,
type: w.type
})),
inputs: n.inputs?.map((i) => ({
name: i.name ?? '',
type: i.type ?? '*',
linkId: i.link ?? null
})),
outputs: n.outputs?.map((o) => ({
name: o.name ?? '',
type: o.type ?? '*',
linkCount: Array.isArray(o.links)
? o.links.filter((l) => l != null).length
: 0
}))
}
}
const graphCmd: Command = async (ctx) => {
const graph = getGraph()
if (!graph) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
const sub = ctx.argv[1] ?? 'summary'
if (sub === 'summary') {
const nodes = (graph as { _nodes: unknown[] })._nodes ?? []
const types = new Map<string, number>()
for (const n of nodes) {
const s = summarizeNode(n)
types.set(s.type, (types.get(s.type) ?? 0) + 1)
}
const lines = [`nodes: ${nodes.length}`, 'types:']
for (const [t, c] of [...types.entries()].sort((a, b) => b[1] - a[1])) {
lines.push(` ${c}\t${t}`)
}
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
if (sub === 'nodes') {
const nodes = (graph as { _nodes: unknown[] })._nodes ?? []
const filter = ctx.argv[2]
const summaries = nodes.map(summarizeNode)
const filtered = filter
? summaries.filter((s) => new RegExp(filter, 'i').test(s.type))
: summaries
const out = filtered
.map((s) => `${s.id}\t${s.type}\t${s.title ?? ''}`)
.join('\n')
return { stdout: stringIter(out + (out ? '\n' : '')), exitCode: 0 }
}
if (sub === 'node') {
const id = ctx.argv[2]
if (!id) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: graph node <id>'
}
}
const nodes = (graph as { _nodes: unknown[] })._nodes ?? []
const node = nodes.find((n) => String((n as { id: number }).id) === id)
if (!node) {
return { stdout: emptyIter(), exitCode: 1, stderr: `no node ${id}` }
}
return {
stdout: stringIter(JSON.stringify(summarizeNode(node), null, 2) + '\n'),
exitCode: 0
}
}
if (sub === 'json') {
const nodes = (graph as { _nodes: unknown[] })._nodes ?? []
const payload = { nodes: nodes.map(summarizeNode) }
return {
stdout: stringIter(JSON.stringify(payload, null, 2) + '\n'),
exitCode: 0
}
}
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `usage: graph <summary|nodes [regex]|node <id>|json>`
}
}
interface LiteWidget {
name?: string
type?: string
value?: unknown
callback?: (v: unknown) => void
}
interface LiteNode {
id: number | string
widgets?: LiteWidget[]
}
function coerce(type: string | undefined, raw: string): unknown {
if (type === 'number' || type === 'INT' || type === 'FLOAT') {
const n = Number(raw)
if (Number.isFinite(n)) return n
}
if (type === 'BOOLEAN' || type === 'toggle') {
if (raw === 'true') return true
if (raw === 'false') return false
}
return raw
}
const setWidget: Command = async (ctx) => {
const [, idArg, name, ...rest] = ctx.argv
if (!idArg || !name || rest.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: set-widget <nodeId> <widgetName> <value...>'
}
}
const graph = getGraph()
if (!graph) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
const nodes = (graph as { _nodes: LiteNode[] })._nodes ?? []
const node = nodes.find((n) => String(n.id) === idArg)
if (!node) {
return { stdout: emptyIter(), exitCode: 1, stderr: `no node ${idArg}` }
}
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `node ${idArg} has no widget "${name}"`
}
}
const value = coerce(widget.type, rest.join(' '))
widget.value = value
widget.callback?.(value)
return {
stdout: stringIter(`set ${idArg}.${name} = ${JSON.stringify(value)}\n`),
exitCode: 0
}
}
export function registerGraphCommands(registry: CommandRegistry): void {
registry.register('graph', graphCmd)
registry.register('set-widget', setWidget)
}

View File

@@ -0,0 +1,140 @@
import { api } from '@/scripts/api'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
/**
* copy-to-input <output_filename> [as <input_filename>]
*
* Copies a file from the output/ directory into input/ so it can be used
* as a LoadImage source in the NEXT workflow. Unlocks multi-phase pipelines
* (e.g. T2I generates image → image-to-3D consumes it) in pure natural
* language via the agent.
*
* Fetches via /view?type=output, re-uploads via /upload/image.
*/
const copyToInput: Command = async (ctx) => {
const args = ctx.argv.slice(1)
if (args.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'usage: copy-to-input <output_filename> [as <input_filename>]\n' +
' copies output/<src> → input/<dst> (defaults dst = src)'
}
}
const src = args[0]
let dst = src
const asIdx = args.indexOf('as')
if (asIdx >= 0 && args[asIdx + 1]) {
dst = args[asIdx + 1]
}
try {
// Fetch the image from ComfyUI's output folder.
const viewUrl = api.apiURL(
`/view?filename=${encodeURIComponent(src)}&type=output`
)
const imgRes = await fetch(viewUrl)
if (!imgRes.ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `copy-to-input: cannot read output/${src} (HTTP ${imgRes.status})`
}
}
const blob = await imgRes.blob()
// Upload into input/.
const form = new FormData()
form.append('image', blob, dst)
form.append('overwrite', 'true')
const uploadRes = await fetch(api.apiURL('/upload/image'), {
method: 'POST',
body: form
})
if (!uploadRes.ok) {
const text = await uploadRes.text().catch(() => '')
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `copy-to-input: upload failed (${uploadRes.status}) ${text.slice(0, 200)}`
}
}
const out = (await uploadRes.json()) as { name?: string }
return {
stdout: stringIter(`copied output/${src} → input/${out.name ?? dst}\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* latest-output-name — print the filename of the most recent SaveImage
* output. Convenience wrapper around latest-output so the LLM can grab
* just the name and pipe it into copy-to-input.
*/
const latestOutputName: Command = async () => {
try {
const res = await fetch(api.apiURL('/history'))
if (!res.ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `latest-output-name: /history ${res.status}`
}
}
const history = (await res.json()) as Record<
string,
{
outputs?: Record<
string,
{
images?: Array<{
filename: string
subfolder?: string
type?: string
}>
}
>
}
>
const entries = Object.values(history)
for (const entry of entries.reverse()) {
const outs = entry.outputs ?? {}
for (const nodeOut of Object.values(outs)) {
const img = nodeOut.images?.[0]
if (img?.filename) {
const sub = img.subfolder ? img.subfolder + '/' : ''
return {
stdout: stringIter(sub + img.filename + '\n'),
exitCode: 0
}
}
}
}
return {
stdout: stringIter(''),
exitCode: 0,
stderr: '(no outputs in history)'
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
export function registerImageCommands(registry: CommandRegistry): void {
registry.register('copy-to-input', copyToInput)
registry.register('latest-output-name', latestOutputName)
}

View File

@@ -0,0 +1,277 @@
import { api } from '@/scripts/api'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
interface ExternalModelEntry {
name: string
type?: string
base: string
save_path: string
filename: string
url: string
}
/**
* Fetch ComfyUI-Manager's curated model list and return a map from url →
* entry. Manager's whitelist check requires save_path+base+filename to match
* an entry; we lift those values from here automatically.
*/
async function fetchManagerModelList(): Promise<ExternalModelEntry[]> {
const res = await fetch(api.apiURL('/externalmodel/getlist?mode=cache'))
if (!res.ok) throw new Error(`externalmodel/getlist ${res.status}`)
const json = (await res.json()) as { models?: ExternalModelEntry[] }
return json.models ?? []
}
/**
* install-model <url> <saveAs>
* OR install-model --find <filename> (search the DB)
*
* Queue a model download in ComfyUI-Manager. <saveAs> is the target path
* relative to ComfyUI's models dir, e.g.:
* install-model https://huggingface.co/.../model.safetensors checkpoints/model.safetensors
*
* The command auto-fills required `base` and exact `save_path` from
* Manager's curated model list (/externalmodel/getlist). If the URL isn't
* recognised, installation will still be attempted with save_path=type,
* but the Manager whitelist may reject it.
*
* Requires ComfyUI-Manager. 404 → manager not available.
*/
const installModel: Command = async (ctx) => {
const args = ctx.argv.slice(1)
if (args[0] === '--find') {
const query = args.slice(1).join(' ').trim()
if (!query) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: install-model --find <filename-substring>'
}
}
try {
const models = await fetchManagerModelList()
const lower = query.toLowerCase()
const matches = models.filter(
(m) =>
m.filename?.toLowerCase().includes(lower) ||
m.name?.toLowerCase().includes(lower)
)
if (matches.length === 0) {
return {
stdout: stringIter('(no matches in manager model list)\n'),
exitCode: 0
}
}
const lines = matches
.slice(0, 20)
.map((m) => `${m.save_path}/${m.filename} [${m.base}]\n ${m.url}`)
lines.push(
'',
`${matches.length} match(es). Use the URL + save_path/filename shown.`
)
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
const [url, saveAs] = args
if (!url || !saveAs) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'usage: install-model <url> <save_path/filename>\n' +
' install-model --find <filename> # search Manager DB\n' +
' example: install-model https://huggingface.co/stabilityai/sdxl-vae/resolve/main/sdxl_vae.safetensors vae/SDXL/sdxl_vae.safetensors'
}
}
const lastSlash = saveAs.lastIndexOf('/')
if (lastSlash <= 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'install-model: <saveAs> must be of the form "<save_path>/<filename>"\n' +
' hint: install-model --find <filename> to look up the exact save_path'
}
}
const savePath = saveAs.slice(0, lastSlash)
const filename = saveAs.slice(lastSlash + 1)
const type = savePath.split('/')[0]
if (!filename) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'install-model: filename is empty'
}
}
// Auto-fill required `base` from Manager's curated list (whitelist check
// in manager_server.py requires save_path + base + filename match).
let base = 'Other'
try {
const models = await fetchManagerModelList()
const entry =
models.find((m) => m.url === url) ??
models.find((m) => m.filename === filename && m.save_path === savePath)
if (entry) base = entry.base
} catch {
/* Manager list unreachable — try anyway */
}
// Legacy endpoint. The v2 routes in the frontend's type schema are only
// present in manager-v4 (pip-installed); most deployments run main.
const uiId =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: String(Date.now())
const body = {
name: filename,
type,
base,
url,
filename,
save_path: savePath,
ui_id: uiId
}
try {
const res = await fetch(api.apiURL('/manager/queue/install_model'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (res.status === 404) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr:
'install-model: ComfyUI-Manager not available on this backend.\n' +
' The user must install it manually and restart ComfyUI.'
}
}
if (res.status === 403) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `install-model: rejected by security policy (403). URL may be on a deny list.`
}
}
if (!res.ok) {
const text = await res.text().catch(() => '')
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `install-model: queue failed (${res.status}) ${text.slice(0, 200)}`
}
}
// Queue must be started after adding tasks (matches manager UI flow).
// Route is POST (legacy Manager) — GET returns 404.
const startRes = await fetch(api.apiURL('/manager/queue/start'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
})
const startOk = startRes.ok || startRes.status === 409 // 409 = already running
return {
stdout: stringIter(
`queued install of ${saveAs} from ${url}\n` +
` ui_id: ${uiId}\n` +
(startOk
? ' queue started — track with: install-status\n'
: ` WARNING: queue-start returned ${startRes.status}; task may not run\n`)
),
exitCode: 0
}
} catch (err) {
// A bare TypeError "Failed to fetch" almost always means the Manager
// route isn't registered (plugin missing) and the request never reached
// a real handler. Surface that explicitly so the user knows to install
// ComfyUI-Manager rather than debugging their network.
const msg = err instanceof Error ? err.message : String(err)
const hint = /Failed to fetch/i.test(msg)
? '\n hint: ComfyUI-Manager plugin is likely not installed on this backend.\n' +
' See: https://github.com/Comfy-Org/ComfyUI-Manager'
: ''
return {
stdout: emptyIter(),
exitCode: 1,
stderr: msg + hint
}
}
}
/**
* install-status
*
* Show the manager install queue: what's running, pending, and recent
* history. Useful right after install-model to watch progress.
*/
interface ManagerQueueStatus {
running_queue?: Array<{ ui_id: string; kind: string; params?: unknown }>
pending_queue?: Array<{ ui_id: string; kind: string; params?: unknown }>
}
const installStatus: Command = async () => {
try {
const statusRes = await fetch(api.apiURL('/manager/queue/status'))
if (statusRes.status === 404) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'install-status: ComfyUI-Manager not available on this backend.'
}
}
if (!statusRes.ok) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `install-status: queue/status failed (${statusRes.status})`
}
}
const status = (await statusRes.json()) as ManagerQueueStatus & {
done_count?: number
in_progress_count?: number
is_processing?: boolean
}
const lines: string[] = []
lines.push(
`processing: ${status.is_processing ? 'yes' : 'no'}` +
` done: ${status.done_count ?? 0}` +
` in_progress: ${status.in_progress_count ?? 0}`
)
lines.push(`running: ${status.running_queue?.length ?? 0}`)
for (const t of status.running_queue ?? []) {
lines.push(` [${t.ui_id.slice(0, 8)}] ${t.kind}`)
}
lines.push(`pending: ${status.pending_queue?.length ?? 0}`)
for (const t of status.pending_queue ?? []) {
lines.push(` [${t.ui_id.slice(0, 8)}] ${t.kind}`)
}
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const hint = /Failed to fetch/i.test(msg)
? '\n hint: ComfyUI-Manager plugin is likely not installed on this backend.'
: ''
return {
stdout: emptyIter(),
exitCode: 1,
stderr: msg + hint
}
}
}
export function registerInstallCommands(registry: CommandRegistry): void {
registry.register('install-model', installModel)
registry.register('install-status', installStatus)
}

View File

@@ -0,0 +1,325 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
/**
* Snapshot current canvas state to ComfyUI's undo stack. Call AFTER a
* bulk mutation so Ctrl/Cmd+Z restores the pre-change layout in one step.
*/
function captureUndo(): void {
try {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch {
/* no-op: no workflow or tracker available */
}
}
/**
* Low-level primitives for managing node geometry on the active canvas.
*
* node-list [--filter <regex>] [--json]
* List nodes with: id, type, posX, posY, sizeW, sizeH, title.
* Tab-separated for easy piping; --json emits machine-readable form.
*
* node-pos <id> → prints 'x y'
* node-pos <id> <x> <y> → sets position
*
* node-size <id> → prints 'w h'
* node-size <id> <w> <h> → sets size
*
* graph-links [--filter <id>]
* List links: id, from-node:from-slot, to-node:to-slot, type.
* Useful for the LLM to compute its own topological / tree layouts.
*
* canvas-redraw
* Trigger a repaint after bulk geometry changes.
*
* With these primitives the agent can implement any layout algorithm
* (tree, dagre, spring, grid, …) entirely in the shell or via run-js.
*/
interface LNode {
id: number
type?: string
comfyClass?: string
title?: string
pos: [number, number]
size: [number, number]
}
interface LLink {
id: number
origin_id: number
origin_slot: number
target_id: number
target_slot: number
type?: string
}
interface LGraphLike {
_nodes: LNode[]
links: Record<number, LLink> | LLink[] | Map<number, LLink>
setDirtyCanvas?: (fg: boolean, bg: boolean) => void
}
function getGraph(): LGraphLike | null {
const g = useCanvasStore().canvas?.graph as LGraphLike | undefined
return g ?? null
}
function iterateLinks(links: LGraphLike['links']): LLink[] {
if (Array.isArray(links)) return links.filter(Boolean)
if (links instanceof Map) return [...links.values()]
return Object.values(links ?? {}).filter((l): l is LLink => !!l)
}
function findNode(g: LGraphLike, id: string): LNode | undefined {
return g._nodes.find((n) => String(n.id) === id)
}
const nodeList: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const filterArg = ctx.argv.find((a) => a.startsWith('--filter='))
const re = filterArg ? new RegExp(filterArg.slice(9), 'i') : null
const json = ctx.argv.includes('--json')
const rows = g._nodes
.filter((n) => !re || re.test(n.comfyClass ?? n.type ?? ''))
.map((n) => ({
id: n.id,
type: n.comfyClass ?? n.type ?? 'Unknown',
x: Math.round(n.pos?.[0] ?? 0),
y: Math.round(n.pos?.[1] ?? 0),
w: Math.round(n.size?.[0] ?? 0),
h: Math.round(n.size?.[1] ?? 0),
title: n.title ?? ''
}))
if (json) {
return {
stdout: stringIter(JSON.stringify(rows, null, 2) + '\n'),
exitCode: 0
}
}
const lines = [
'id\ttype\tx\ty\tw\th\ttitle',
...rows.map(
(r) => `${r.id}\t${r.type}\t${r.x}\t${r.y}\t${r.w}\t${r.h}\t${r.title}`
)
]
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
const nodePos: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const [, id, xArg, yArg] = ctx.argv
if (!id) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: node-pos <id> [<x> <y>]'
}
}
const n = findNode(g, id)
if (!n) return { stdout: emptyIter(), exitCode: 1, stderr: `no node ${id}` }
if (xArg === undefined) {
return {
stdout: stringIter(`${Math.round(n.pos[0])} ${Math.round(n.pos[1])}\n`),
exitCode: 0
}
}
const x = Number(xArg)
const y = Number(yArg)
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'x and y must be numbers'
}
}
n.pos = [x, y]
g.setDirtyCanvas?.(true, true)
captureUndo()
return { stdout: stringIter(`set ${id} pos=${x},${y}\n`), exitCode: 0 }
}
const nodeSize: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const [, id, wArg, hArg] = ctx.argv
if (!id) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: node-size <id> [<w> <h>]'
}
}
const n = findNode(g, id)
if (!n) return { stdout: emptyIter(), exitCode: 1, stderr: `no node ${id}` }
if (wArg === undefined) {
return {
stdout: stringIter(`${Math.round(n.size[0])} ${Math.round(n.size[1])}\n`),
exitCode: 0
}
}
const w = Number(wArg)
const h = Number(hArg)
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'w and h must be positive numbers'
}
}
n.size = [w, h]
g.setDirtyCanvas?.(true, true)
captureUndo()
return { stdout: stringIter(`set ${id} size=${w}x${h}\n`), exitCode: 0 }
}
const graphLinks: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const filterArg = ctx.argv.find((a) => a.startsWith('--filter='))
const nodeFilter = filterArg ? filterArg.slice(9) : null
const rows = iterateLinks(g.links)
.filter((l) =>
nodeFilter
? String(l.origin_id) === nodeFilter ||
String(l.target_id) === nodeFilter
: true
)
.map(
(l) =>
`${l.id}\t${l.origin_id}:${l.origin_slot}\t→\t${l.target_id}:${l.target_slot}\t${l.type ?? ''}`
)
const header = 'link\tfrom\t\tto\ttype'
return {
stdout: stringIter([header, ...rows].join('\n') + '\n'),
exitCode: 0
}
}
const canvasRedraw: Command = async () => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
g.setDirtyCanvas?.(true, true)
return { stdout: stringIter('canvas redrawn\n'), exitCode: 0 }
}
/**
* graph-dot — emit a DOT-like text description of the graph. Nodes are
* labelled by id and type, with size and current position. Directed edges
* follow slot-to-slot links. This is a compact, human/LLM-readable view
* the agent can use as input when reasoning about a layout.
*/
const graphDot: Command = async () => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
const lines: string[] = []
lines.push('digraph graph {')
lines.push(' rankdir=TB;')
for (const n of g._nodes) {
const type = n.comfyClass ?? n.type ?? 'Unknown'
const x = Math.round(n.pos?.[0] ?? 0)
const y = Math.round(n.pos?.[1] ?? 0)
const w = Math.round(n.size?.[0] ?? 0)
const h = Math.round(n.size?.[1] ?? 0)
lines.push(` ${n.id} [label="${type}" pos="${x},${y}" size="${w}x${h}"];`)
}
for (const l of iterateLinks(g.links)) {
lines.push(` ${l.origin_id} -> ${l.target_id};`)
}
lines.push('}')
return { stdout: stringIter(lines.join('\n') + '\n'), exitCode: 0 }
}
/**
* apply-layout — accept JSON (from stdin or arg) describing bulk
* position / size updates. Shape:
* [{"id": 3, "pos": [100, 100], "size": [240, 160]}, ...]
* Unknown ids are skipped. One redraw at the end.
*/
const applyLayout: Command = async (ctx) => {
const g = getGraph()
if (!g) return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
let input = ''
const inline = ctx.argv.slice(1).join(' ').trim()
if (inline) input = inline
else {
const chunks: string[] = []
for await (const c of ctx.stdin) chunks.push(c)
input = chunks.join('')
}
if (!input.trim()) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: apply-layout <json> | echo <json> | apply-layout'
}
}
let parsed: unknown
try {
parsed = JSON.parse(input)
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr:
'invalid JSON: ' + (err instanceof Error ? err.message : String(err))
}
}
if (!Array.isArray(parsed)) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'expected JSON array of {id, pos?, size?}'
}
}
let updated = 0
let skipped = 0
for (const item of parsed as Array<{
id?: number | string
pos?: [number, number]
size?: [number, number]
}>) {
if (item?.id === undefined) {
skipped++
continue
}
const n = findNode(g, String(item.id))
if (!n) {
skipped++
continue
}
if (Array.isArray(item.pos) && item.pos.length === 2) {
const [x, y] = item.pos
if (Number.isFinite(x) && Number.isFinite(y)) n.pos = [x, y]
}
if (Array.isArray(item.size) && item.size.length === 2) {
const [w, h] = item.size
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0)
n.size = [w, h]
}
updated++
}
g.setDirtyCanvas?.(true, true)
captureUndo()
return {
stdout: stringIter(
`applied: ${updated} nodes, skipped: ${skipped} — Ctrl/Cmd+Z to undo\n`
),
exitCode: 0
}
}
export function registerLayoutCommands(registry: CommandRegistry): void {
registry.register('node-list', nodeList)
registry.register('node-pos', nodePos)
registry.register('node-size', nodeSize)
registry.register('graph-links', graphLinks)
registry.register('graph-dot', graphDot)
registry.register('apply-layout', applyLayout)
registry.register('canvas-redraw', canvasRedraw)
}

View File

@@ -0,0 +1,858 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { useCommandStore } from '@/stores/commandStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
interface PosSizeNode {
id: number | string
pos: [number, number]
size: [number, number]
comfyClass?: string
type?: string
}
function captureUndo(): void {
try {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch {
/* no-op */
}
}
function getCanvas() {
return useCanvasStore().canvas
}
function getGraph() {
return getCanvas()?.graph ?? null
}
function getSelectedNodes(): PosSizeNode[] {
const canvas = getCanvas()
if (!canvas) return []
const selected = (canvas as { selected_nodes?: Record<string, PosSizeNode> })
.selected_nodes
if (!selected) return []
return Object.values(selected)
}
/**
* node-search <pattern>
*
* Returns matching node type names from LiteGraph.registered_node_types.
* Case-insensitive substring or regex match. One per line, sorted.
*/
const nodeSearch: Command = async (ctx) => {
const pattern = ctx.argv.slice(1).join(' ').trim()
if (!pattern) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: node-search <pattern>'
}
}
const registered = LiteGraph.registered_node_types ?? {}
let regex: RegExp
try {
regex = new RegExp(pattern, 'i')
} catch {
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
regex = new RegExp(escaped, 'i')
}
const matches = Object.keys(registered)
.filter((type) => regex.test(type))
.sort()
if (matches.length === 0) {
return { stdout: stringIter(''), exitCode: 0 }
}
return {
stdout: stringIter(matches.join('\n') + '\n'),
exitCode: 0
}
}
/**
* add-node <type> [x] [y]
*
* Create a node of the given registered type and add it to the active
* graph. Positions at [x, y] (default [100, 100]). Prints the new node id.
*/
interface ViewportCanvas {
ds?: { offset: [number, number]; scale: number }
canvas?: { width: number; height: number }
}
/**
* Pick a non-overlapping position near the viewport center. Scans outward
* in a spiral grid until it finds a cell that doesn't intersect any
* existing node's AABB. Returns the top-left for the new node.
*/
function pickEmptySpot(
graph: { _nodes?: PosSizeNode[] },
canvas: ViewportCanvas,
nodeSize: [number, number] = [220, 100]
): [number, number] {
const nodes = graph._nodes ?? []
const ds = canvas.ds
const vp = canvas.canvas
let centerX = 0
let centerY = 0
if (ds && vp) {
// Viewport center in graph coords: (-offset + viewport/2) / scale
centerX = (-ds.offset[0] + vp.width / 2) / ds.scale
centerY = (-ds.offset[1] + vp.height / 2) / ds.scale
} else if (nodes.length > 0) {
centerX = nodes.reduce((s, n) => s + n.pos[0], 0) / nodes.length
centerY = nodes.reduce((s, n) => s + n.pos[1], 0) / nodes.length
}
const [w, h] = nodeSize
const pad = 40
const stepX = w + pad
const stepY = h + pad
const overlaps = (x: number, y: number): boolean =>
nodes.some((n) => {
const [nx, ny] = n.pos
const [nw, nh] = n.size ?? [220, 100]
return !(x + w < nx || nx + nw < x || y + h < ny || ny + nh < y)
})
const origin: [number, number] = [centerX - w / 2, centerY - h / 2]
if (!overlaps(origin[0], origin[1])) return origin
// Spiral outward: rings of radius r, check each grid cell.
for (let r = 1; r < 40; r++) {
for (let dx = -r; dx <= r; dx++) {
for (let dy = -r; dy <= r; dy++) {
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue
const x = origin[0] + dx * stepX
const y = origin[1] + dy * stepY
if (!overlaps(x, y)) return [x, y]
}
}
}
return origin
}
const addNode: Command = async (ctx) => {
const [, typeArg, xArg, yArg] = ctx.argv
if (!typeArg) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: add-node <type> [x] [y]'
}
}
const graph = getGraph()
if (!graph) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
if (!LiteGraph.registered_node_types?.[typeArg]) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `add-node: unknown type "${typeArg}" — try: node-search <pattern>`
}
}
const xyGiven = xArg !== undefined && yArg !== undefined
const x = xArg !== undefined ? Number(xArg) : Number.NaN
const y = yArg !== undefined ? Number(yArg) : Number.NaN
if (xyGiven && (!Number.isFinite(x) || !Number.isFinite(y))) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'add-node: x and y must be numbers'
}
}
try {
const node = LiteGraph.createNode(typeArg)
if (!node) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `add-node: failed to create node of type "${typeArg}"`
}
}
if (xyGiven) {
node.pos = [x, y]
} else {
const canvas = getCanvas() as unknown as ViewportCanvas
const pos = pickEmptySpot(
graph as { _nodes?: PosSizeNode[] },
canvas,
(node as { size?: [number, number] }).size ?? [220, 100]
)
node.pos = pos
}
;(graph as { add: (n: unknown) => void }).add(node)
getCanvas()?.setDirty(true, true)
captureUndo()
return {
stdout: stringIter(`${(node as { id: number | string }).id}\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
type AlignAxis = 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y'
const ALIGN_AXES: readonly AlignAxis[] = [
'left',
'right',
'center-x',
'top',
'bottom',
'center-y'
]
/**
* align-nodes <axis>
*
* Align currently-selected nodes to a common edge/center on the given axis.
* Axis: left | right | center-x | top | bottom | center-y
*/
const alignNodes: Command = async (ctx) => {
const axis = ctx.argv[1] as AlignAxis | undefined
if (!axis || !ALIGN_AXES.includes(axis)) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `usage: align-nodes <${ALIGN_AXES.join('|')}>`
}
}
const selected = getSelectedNodes()
if (selected.length < 2) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'align-nodes: select at least 2 nodes'
}
}
const xs = selected.map((n) => n.pos[0])
const ys = selected.map((n) => n.pos[1])
const rights = selected.map((n) => n.pos[0] + (n.size?.[0] ?? 0))
const bottoms = selected.map((n) => n.pos[1] + (n.size?.[1] ?? 0))
for (const n of selected) {
const w = n.size?.[0] ?? 0
const h = n.size?.[1] ?? 0
if (axis === 'left') n.pos[0] = Math.min(...xs)
else if (axis === 'right') n.pos[0] = Math.max(...rights) - w
else if (axis === 'center-x') {
const cx =
(Math.min(...xs) +
Math.max(...selected.map((s) => s.pos[0] + (s.size?.[0] ?? 0)))) /
2
n.pos[0] = cx - w / 2
} else if (axis === 'top') n.pos[1] = Math.min(...ys)
else if (axis === 'bottom') n.pos[1] = Math.max(...bottoms) - h
else if (axis === 'center-y') {
const cy =
(Math.min(...ys) +
Math.max(...selected.map((s) => s.pos[1] + (s.size?.[1] ?? 0)))) /
2
n.pos[1] = cy - h / 2
}
}
getCanvas()?.setDirty(true, true)
captureUndo()
return {
stdout: stringIter(`aligned ${selected.length} nodes (${axis})\n`),
exitCode: 0
}
}
/**
* distribute-nodes <h|v>
*
* Distribute selected nodes evenly along horizontal (h) or vertical (v)
* axis between the first and last node's current positions.
*/
const distributeNodes: Command = async (ctx) => {
const axis = ctx.argv[1]
if (axis !== 'h' && axis !== 'v') {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: distribute-nodes <h|v>'
}
}
const selected = getSelectedNodes()
if (selected.length < 3) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'distribute-nodes: select at least 3 nodes'
}
}
const dim = axis === 'h' ? 0 : 1
const sorted = [...selected].sort((a, b) => a.pos[dim] - b.pos[dim])
const first = sorted[0].pos[dim]
const last = sorted[sorted.length - 1].pos[dim]
const step = (last - first) / (sorted.length - 1)
sorted.forEach((n, i) => {
n.pos[dim] = first + step * i
})
getCanvas()?.setDirty(true, true)
captureUndo()
return {
stdout: stringIter(
`distributed ${sorted.length} nodes along ${axis === 'h' ? 'horizontal' : 'vertical'}\n`
),
exitCode: 0
}
}
/**
* toggle-panel <name>
*
* Open/close a right-side or left-side sidebar tab by name.
*
* Right-side panel tabs: parameters | nodes | settings | info | subgraph | errors
* Left-side sidebar tabs: whatever is registered (queue, history, assets, workflows, models, node-library, apps)
*/
const RIGHT_TABS: readonly RightSidePanelTab[] = [
'parameters',
'nodes',
'settings',
'info',
'subgraph',
'errors'
]
const togglePanel: Command = async (ctx) => {
const name = ctx.argv[1]?.trim().toLowerCase()
if (!name) {
const right = `right: ${RIGHT_TABS.join(', ')}`
const leftTabs = useSidebarTabStore()
.sidebarTabs.map((t) => t.id)
.join(', ')
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `usage: toggle-panel <name>\n ${right}\n left (sidebar): ${leftTabs}`
}
}
// Queue + history are command-driven overlays, not sidebar tabs — route
// them through the command store so the user's mental model ("open the
// queue panel") still works.
const overlayCommands: Record<string, string> = {
queue: 'Comfy.Queue.ToggleOverlay',
history: 'Comfy.Queue.ToggleOverlay',
'job-history': 'Comfy.Queue.ToggleOverlay'
}
if (overlayCommands[name]) {
try {
await useCommandStore().execute(overlayCommands[name])
return {
stdout: stringIter(`toggled ${name} overlay\n`),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
// Alias common names to panel/sidebar ids. Only alias names that we know
// map to a real registered tab id in this build.
const aliases: Record<string, string> = {
'missing-models': 'errors',
'model-library': 'models',
'node-library': 'node-library'
}
const resolved = aliases[name] ?? name
if ((RIGHT_TABS as readonly string[]).includes(resolved)) {
const store = useRightSidePanelStore()
const isSame = store.activeTab === resolved && store.isOpen
if (isSame) {
store.closePanel()
return {
stdout: stringIter(`closed right panel (${resolved})\n`),
exitCode: 0
}
}
store.openPanel(resolved as RightSidePanelTab)
return {
stdout: stringIter(`opened right panel (${resolved})\n`),
exitCode: 0
}
}
const sidebar = useSidebarTabStore()
const tab = sidebar.sidebarTabs.find((t) => t.id === resolved)
if (!tab) {
const known = sidebar.sidebarTabs.map((t) => t.id).join(', ')
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `toggle-panel: unknown panel "${name}"\n right: ${RIGHT_TABS.join(', ')}\n sidebar: ${known}`
}
}
sidebar.toggleSidebarTab(tab.id)
const nowActive = sidebar.activeSidebarTabId === tab.id
return {
stdout: stringIter(
`${nowActive ? 'opened' : 'closed'} sidebar tab (${tab.id})\n`
),
exitCode: 0
}
}
/**
* select <idOrSpec...>
*
* Select one or more nodes. Accepts:
* - node ids: select 3 5 7
* - type filter: select type=KSampler
* - "all": select all
* - "none": select none (clears)
*
* Needed before align-nodes / distribute-nodes.
*/
interface CanvasWithSelection {
selected_nodes: Record<string, unknown>
selectNode?: (node: unknown, keep?: boolean) => void
deselectAllNodes?: () => void
setDirty?: (a: boolean, b: boolean) => void
}
const selectCmd: Command = async (ctx) => {
const args = ctx.argv.slice(1)
if (args.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: select <id...> | type=<Type> | all | none'
}
}
const graph = getGraph() as { _nodes?: unknown[] } | null
const canvas = getCanvas() as unknown as CanvasWithSelection | null
if (!graph?._nodes || !canvas) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
canvas.deselectAllNodes?.()
const nodes = graph._nodes as Array<{ id: number; type?: string }>
let picked: typeof nodes = []
if (args[0] === 'none') {
canvas.setDirty?.(true, true)
return { stdout: stringIter('selection cleared\n'), exitCode: 0 }
}
if (args[0] === 'all') {
picked = nodes
} else {
for (const a of args) {
if (a.startsWith('type=')) {
const t = a.slice(5)
picked.push(...nodes.filter((n) => n.type === t))
} else if (/^\d+$/.test(a)) {
const id = Number(a)
const n = nodes.find((node) => node.id === id)
if (n) picked.push(n)
} else {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: `select: unrecognised token "${a}" (expected id, type=X, all, or none)`
}
}
}
}
for (const n of picked) canvas.selectNode?.(n, true)
canvas.setDirty?.(true, true)
return {
stdout: stringIter(
`selected ${picked.length} node${picked.length === 1 ? '' : 's'}: ${picked
.map((n) => n.id)
.join(', ')}\n`
),
exitCode: 0
}
}
/**
* connect <fromId>.<output> <toId>.<input>
*
* Create a link. output/input may be the socket index (0-based) or name.
* Example:
* connect 3.0 5.0 # first output of node 3 → first input of 5
* connect 3.LATENT 5.samples # by socket name
*/
interface LinkableNode {
id: number
outputs?: Array<{ name?: string }>
inputs?: Array<{ name?: string }>
connect: (fromSlot: number, target: LinkableNode, toSlot: number) => unknown
}
function resolveSlot(
socket: string,
slots: Array<{ name?: string }> | undefined
): number | null {
if (!slots) return null
if (/^\d+$/.test(socket)) {
const i = Number(socket)
return i >= 0 && i < slots.length ? i : null
}
const idx = slots.findIndex((s) => s.name === socket)
return idx >= 0 ? idx : null
}
const connectCmd: Command = async (ctx) => {
const [, from, to] = ctx.argv
if (!from || !to) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: connect <fromId>.<output> <toId>.<input>'
}
}
const fromMatch = from.match(/^(\d+)\.(.+)$/)
const toMatch = to.match(/^(\d+)\.(.+)$/)
if (!fromMatch || !toMatch) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'connect: both args must be <id>.<socket>'
}
}
const graph = getGraph() as { _nodes?: LinkableNode[] } | null
const nodes = graph?._nodes
if (!nodes) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
const fromNode = nodes.find((n) => n.id === Number(fromMatch[1]))
const toNode = nodes.find((n) => n.id === Number(toMatch[1]))
if (!fromNode || !toNode) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `connect: node not found (${!fromNode ? fromMatch[1] : toMatch[1]})`
}
}
const fromSlot = resolveSlot(fromMatch[2], fromNode.outputs)
const toSlot = resolveSlot(toMatch[2], toNode.inputs)
if (fromSlot === null || toSlot === null) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `connect: socket not found (from=${fromMatch[2]} to=${toMatch[2]})`
}
}
try {
const link = fromNode.connect(fromSlot, toNode, toSlot)
if (!link) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: 'connect: link rejected (type mismatch?)'
}
}
getCanvas()?.setDirty(true, true)
captureUndo()
// Auto-layout after successful connect so the canvas stays readable.
// Opt out with --no-layout for users hand-placing nodes.
const suppress = ctx.argv.includes('--no-layout')
let extra = ''
if (!suppress) {
extra = ' + ' + runLayout('lr')
}
return {
stdout: stringIter(
`connected ${fromNode.id}.${fromMatch[2]}${toNode.id}.${toMatch[2]}${extra}\n`
),
exitCode: 0
}
} catch (err) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: err instanceof Error ? err.message : String(err)
}
}
}
/**
* layout [lr|tb]
*
* Topological tree layout of the active graph. `lr` = left-to-right (default,
* natural for ComfyUI pipelines); `tb` = top-to-bottom. Uses longest-path
* levelling with stable within-level ordering by id. Captures undo.
*/
interface LayoutNode {
id: number
pos: [number, number]
size?: [number, number]
}
interface LayoutLink {
origin_id: number
target_id: number
}
function runLayout(direction: 'lr' | 'tb' = 'lr'): string {
const graph = getGraph() as {
_nodes?: LayoutNode[]
links?: LayoutLink[] | Record<string, LayoutLink>
} | null
const nodes = graph?._nodes
if (!nodes || nodes.length === 0) return 'layout: nothing to do'
const rawLinks = graph?.links
const links: LayoutLink[] = Array.isArray(rawLinks)
? rawLinks.filter(Boolean)
: Object.values(rawLinks ?? {}).filter(Boolean)
const parents = new Map<number, Set<number>>()
for (const n of nodes) parents.set(n.id, new Set())
for (const l of links) parents.get(l.target_id)?.add(l.origin_id)
const lvl = new Map<number, number>()
for (const n of nodes) lvl.set(n.id, 0)
let changed = true
let guard = nodes.length * 2
while (changed && guard-- > 0) {
changed = false
for (const n of nodes) {
let m = -1
for (const p of parents.get(n.id) ?? []) m = Math.max(m, lvl.get(p) ?? 0)
if (m + 1 > (lvl.get(n.id) ?? 0)) {
lvl.set(n.id, m + 1)
changed = true
}
}
}
const byLv = new Map<number, LayoutNode[]>()
for (const n of nodes) {
const k = lvl.get(n.id) ?? 0
if (!byLv.has(k)) byLv.set(k, [])
byLv.get(k)?.push(n)
}
const keys = [...byLv.keys()].sort((a, b) => a - b)
if (direction === 'lr') {
let x = 60
for (const k of keys) {
const col = (byLv.get(k) ?? []).sort((a, b) => a.id - b.id)
let y = 60
let maxW = 0
for (const n of col) {
n.pos = [x, y]
y += (n.size?.[1] ?? 100) + 40
maxW = Math.max(maxW, n.size?.[0] ?? 220)
}
x += maxW + 60
}
} else {
let y = 60
for (const k of keys) {
const row = (byLv.get(k) ?? []).sort((a, b) => a.id - b.id)
let x = 60
let maxH = 0
for (const n of row) {
n.pos = [x, y]
x += (n.size?.[0] ?? 220) + 40
maxH = Math.max(maxH, n.size?.[1] ?? 100)
}
y += maxH + 60
}
}
getCanvas()?.setDirty(true, true)
captureUndo()
return `laid out ${nodes.length} nodes (${direction})`
}
const layoutCmd: Command = async (ctx) => {
const dir = (ctx.argv[1] ?? 'lr').toLowerCase()
if (dir !== 'lr' && dir !== 'tb') {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: layout [lr|tb]'
}
}
return { stdout: stringIter(runLayout(dir) + '\n'), exitCode: 0 }
}
/**
* disconnect <id>.<input>
*
* Remove the link feeding a specific input socket. Auto-layouts afterwards
* (opt out with --no-layout). To clear multiple, call repeatedly.
*/
interface DisconnectableNode {
id: number
inputs?: Array<{ name?: string }>
disconnectInput: (slot: number) => boolean
}
const disconnectCmd: Command = async (ctx) => {
const target = ctx.argv[1]
if (!target) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: disconnect <id>.<input> [--no-layout]'
}
}
const match = target.match(/^(\d+)\.(.+)$/)
if (!match) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'disconnect: arg must be <id>.<socket>'
}
}
const graph = getGraph() as { _nodes?: DisconnectableNode[] } | null
const node = graph?._nodes?.find((n) => n.id === Number(match[1]))
if (!node) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `disconnect: no node ${match[1]}`
}
}
const slot = resolveSlot(match[2], node.inputs)
if (slot === null) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `disconnect: unknown input "${match[2]}" on node ${node.id}`
}
}
const ok = node.disconnectInput(slot)
getCanvas()?.setDirty(true, true)
captureUndo()
const suppress = ctx.argv.includes('--no-layout')
const extra = !suppress ? ' + ' + runLayout('lr') : ''
return {
stdout: stringIter(
ok
? `disconnected ${node.id}.${match[2]}${extra}\n`
: `disconnect: ${node.id}.${match[2]} was not connected\n`
),
exitCode: 0
}
}
/**
* remove-node <id...>
*
* Delete one or more nodes from the active graph. Auto-layouts after.
*/
interface RemovableGraph {
_nodes?: Array<{ id: number }>
remove: (node: unknown) => void
}
const removeNode: Command = async (ctx) => {
const ids = ctx.argv
.slice(1)
.filter((a) => !a.startsWith('--'))
.map((a) => Number(a))
.filter((n) => Number.isFinite(n))
if (ids.length === 0) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: remove-node <id...> [--no-layout]'
}
}
const graph = getGraph() as unknown as RemovableGraph | null
if (!graph?._nodes) {
return { stdout: emptyIter(), exitCode: 1, stderr: 'no active graph' }
}
const removed: number[] = []
for (const id of ids) {
const n = graph._nodes.find((x) => x.id === id)
if (n) {
graph.remove(n)
removed.push(id)
}
}
getCanvas()?.setDirty(true, true)
captureUndo()
const suppress = ctx.argv.includes('--no-layout')
const extra = !suppress && removed.length > 0 ? ' + ' + runLayout('lr') : ''
return {
stdout: stringIter(
`removed ${removed.length} node(s): ${removed.join(', ')}${extra}\n`
),
exitCode: 0
}
}
/**
* get-widget <id> <name>
*
* Read a widget's current value. Complements set-widget.
*/
interface WidgetCarrier {
id: number
widgets?: Array<{ name?: string; value?: unknown }>
}
const getWidget: Command = async (ctx) => {
const [, idArg, nameArg] = ctx.argv
if (!idArg || !nameArg) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: get-widget <id> <name>'
}
}
const graph = getGraph() as { _nodes?: WidgetCarrier[] } | null
const node = graph?._nodes?.find((n) => n.id === Number(idArg))
if (!node) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `get-widget: no node ${idArg}`
}
}
const widget = node.widgets?.find((w) => w.name === nameArg)
if (!widget) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `get-widget: no widget "${nameArg}" on node ${idArg}`
}
}
return {
stdout: stringIter(JSON.stringify(widget.value) + '\n'),
exitCode: 0
}
}
export function registerNodeOpsCommands(registry: CommandRegistry): void {
registry.register('node-search', nodeSearch)
registry.register('add-node', addNode)
registry.register('align-nodes', alignNodes)
registry.register('distribute-nodes', distributeNodes)
registry.register('toggle-panel', togglePanel)
registry.register('select', selectCmd)
registry.register('connect', connectCmd)
registry.register('get-widget', getWidget)
registry.register('layout', layoutCmd)
registry.register('disconnect', disconnectCmd)
registry.register('remove-node', removeNode)
}

View File

@@ -0,0 +1,176 @@
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import type { components } from '@comfyorg/registry-types'
import type { Command, CommandRegistry } from '../types'
import { emptyIter, stringIter } from '../types'
type Pack = components['schemas']['Node']
type ComfyNode = components['schemas']['ComfyNode']
const DEFAULT_LIMIT = 20
function packLine(p: Pack): string {
const id = p.id ?? '?'
const ver = p.latest_version?.version ?? 'unknown'
const name = p.name ?? id
const desc = (p.description ?? '').replace(/\s+/g, ' ').slice(0, 80)
return `${id}@${ver} ${name}${desc ? ' — ' + desc : ''}`
}
function nodeLine(n: ComfyNode): string {
const name = n.comfy_node_name ?? '?'
const cat = n.category ?? ''
const desc = (n.description ?? '').replace(/\s+/g, ' ').slice(0, 80)
const tail = [cat, desc].filter(Boolean).join(' — ')
return tail ? `${name} (${tail})` : name
}
/**
* node-search-registry <pattern>
*
* Search the public Comfy Registry for node-classes matching <pattern>
* across ALL published custom-node packs — including ones the user has
* not installed locally. Use this when local `node-search` returns no
* results: the node may exist in a pack that hasn't been installed yet.
*
* Output: one pack per line with install hint underneath.
*/
const nodeSearchRegistry: Command = async (ctx) => {
const pattern = ctx.argv.slice(1).join(' ').trim()
if (!pattern) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: node-search-registry <pattern>'
}
}
const svc = useComfyRegistryService()
const res = await svc.search({
comfy_node_search: pattern,
limit: DEFAULT_LIMIT
})
const packs = res?.nodes ?? []
if (packs.length === 0) {
return {
stdout: stringIter(
`no registry packs expose a node matching "${pattern}".\n` +
'note: registry only indexes published packs. Try `pack-search ' +
pattern +
'` for pack-name/description match, or fall back to a github repo search.\n'
),
exitCode: 0
}
}
const total = res?.total ?? packs.length
const lines = packs.map(
(p) => packLine(p) + (p.id ? '\n inspect: pack-info ' + p.id : '')
)
const header =
packs.length < total
? `${packs.length} of ${total} pack(s) expose a node matching "${pattern}". To install one, ask the user to use ComfyUI-Manager (Settings → Extensions) — there is no shell command for pack installs yet.\n`
: `${packs.length} pack(s) expose a node matching "${pattern}". To install one, ask the user to use ComfyUI-Manager (Settings → Extensions) — there is no shell command for pack installs yet.\n`
return {
stdout: stringIter(header + lines.join('\n') + '\n'),
exitCode: 0
}
}
/**
* pack-search <pattern>
*
* Search the public Comfy Registry for packs whose name or description
* matches <pattern>. Complements `node-search-registry` (which matches
* node-class names) — use this when looking for a pack by topic rather
* than by a specific node-class.
*/
const packSearch: Command = async (ctx) => {
const pattern = ctx.argv.slice(1).join(' ').trim()
if (!pattern) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: pack-search <pattern>'
}
}
const svc = useComfyRegistryService()
const res = await svc.search({ search: pattern, limit: DEFAULT_LIMIT })
const packs = res?.nodes ?? []
if (packs.length === 0) {
return {
stdout: stringIter(`no registry packs match "${pattern}".\n`),
exitCode: 0
}
}
const total = res?.total ?? packs.length
const header =
packs.length < total
? `${packs.length} of ${total} pack(s) match "${pattern}":\n`
: `${packs.length} pack(s) match "${pattern}":\n`
const lines = packs.map(
(p) => packLine(p) + (p.id ? '\n inspect: pack-info ' + p.id : '')
)
return {
stdout: stringIter(header + lines.join('\n') + '\n'),
exitCode: 0
}
}
/**
* pack-info <pack_id>
*
* List all node-classes provided by <pack_id>'s latest version. Use this
* to verify a pack actually contains the node you want before installing
* it — registry node-search returns the pack, but not the full node list.
*/
const packInfo: Command = async (ctx) => {
const packId = ctx.argv[1]?.trim()
if (!packId) {
return {
stdout: emptyIter(),
exitCode: 2,
stderr: 'usage: pack-info <pack_id>'
}
}
const svc = useComfyRegistryService()
const pack = await svc.getPackById(packId)
if (!pack) {
return {
stdout: emptyIter(),
exitCode: 1,
stderr: `pack-info: pack "${packId}" not found in registry`
}
}
const version = pack.latest_version?.version
if (!version) {
return {
stdout: stringIter(packLine(pack) + '\n (no published version)\n'),
exitCode: 0
}
}
const defs = await svc.getNodeDefs({ packId, version })
const nodes = defs?.comfy_nodes ?? []
const head = packLine(pack)
if (nodes.length === 0) {
return {
stdout: stringIter(head + '\n (this pack publishes no node defs)\n'),
exitCode: 0
}
}
return {
stdout: stringIter(
head +
`\nnodes (${nodes.length}):\n` +
nodes.map((n) => ' ' + nodeLine(n)).join('\n') +
'\n'
),
exitCode: 0
}
}
export function registerRegistrySearchCommands(
registry: CommandRegistry
): void {
registry.register('node-search-registry', nodeSearchRegistry)
registry.register('pack-search', packSearch)
registry.register('pack-info', packInfo)
}

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