Compare commits

...

51 Commits

Author SHA1 Message Date
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
/ 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
26 changed files with 1870 additions and 22 deletions

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

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

@@ -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

@@ -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

@@ -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

@@ -0,0 +1,33 @@
<template>
<div class="relative">
<code
class="block rounded-md bg-neutral-800 p-3 pr-10 text-xs whitespace-pre-wrap text-neutral-200 select-all"
>{{ text }}</code
>
<button
:title="copied ? t('clipboard.successMessage') : t('g.copyToClipboard')"
:aria-label="
copied ? t('clipboard.successMessage') : t('g.copyToClipboard')
"
class="absolute top-2 right-2 rounded-sm p-1 text-neutral-500 transition-colors hover:text-neutral-100"
@click="copy(text)"
>
<span
:class="
copied ? 'icon-[lucide--check] text-green-400' : 'icon-[lucide--copy]'
"
class="block size-3.5"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
const { text } = defineProps<{ text: string }>()
const { t } = useI18n()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
</script>

View File

@@ -42,6 +42,19 @@
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
<template v-if="badge.popoverLinks?.length">
<hr class="border-border-default" />
<a
v-for="link in badge.popoverLinks"
:key="link.url"
:href="link.url"
:target="link.url.startsWith('http') ? '_blank' : undefined"
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
>
{{ link.label }}
</a>
</template>
</div>
</Popover>
</div>
@@ -96,6 +109,19 @@
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
<template v-if="badge.popoverLinks?.length">
<hr class="border-border-default" />
<a
v-for="link in badge.popoverLinks"
:key="link.url"
:href="link.url"
:target="link.url.startsWith('http') ? '_blank' : undefined"
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
>
{{ link.label }}
</a>
</template>
</div>
</Popover>
</div>
@@ -103,10 +129,15 @@
<!-- Full mode: Icon + Label + Text -->
<div
v-else
v-tooltip="badge.tooltip"
class="flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
:class="[{ 'flex-row-reverse': reverseOrder }, noPadding ? '' : 'px-3']"
v-tooltip="badge.popoverLinks?.length ? undefined : badge.tooltip"
class="relative flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
:class="[
{ 'flex-row-reverse': reverseOrder },
noPadding ? '' : 'px-3',
badge.popoverLinks?.length ? clickableClasses : ''
]"
:style="menuBackgroundStyle"
@click="badge.popoverLinks?.length ? togglePopover($event) : undefined"
>
<i
v-if="iconClass"
@@ -123,6 +154,30 @@
<div class="font-inter text-sm" :class="textClasses">
{{ badge.text }}
</div>
<Popover
v-if="badge.popoverLinks?.length"
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="popoverPt"
>
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<a
v-for="link in badge.popoverLinks"
:key="link.url"
:href="link.url"
:target="link.url.startsWith('http') ? '_blank' : undefined"
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
>
{{ link.label }}
</a>
</div>
</Popover>
</div>
</template>
<script setup lang="ts">

View File

@@ -41,3 +41,15 @@ export function getComfyPlatformBaseUrl(): string {
BUILD_TIME_PLATFORM_BASE_URL
)
}
/**
* Maps a Comfy Cloud API base URL (as reported by the backend) to its paired
* platform URL where users manage their account / API keys. Returns null for
* unknown bases so callers can hide the link rather than guess.
*/
export function getPlatformBaseUrlForApiBase(apiBase: string): string | null {
const normalized = apiBase.replace(/\/+$/, '')
if (normalized === PROD_API_BASE_URL) return PROD_PLATFORM_BASE_URL
if (normalized === STAGING_API_BASE_URL) return STAGING_PLATFORM_BASE_URL
return null
}

View File

@@ -52,3 +52,8 @@ if (isCloud || isNightly) {
if (isNightly && !isCloud) {
await import('./nightlyBadges')
}
// PR preview build badge
if (__CI_PR_NUMBER__) {
await import('./prPreviewBadges')
}

View File

@@ -0,0 +1,82 @@
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionService } from '@/services/extensionService'
import type { AboutPageBadge, TopbarBadge } from '@/types/comfy'
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
const prNumber = __CI_PR_NUMBER__
const author = __CI_PR_AUTHOR__
const commit = __COMFYUI_FRONTEND_COMMIT__
const commitShort = commit ? commit.slice(0, 8) : ''
const settingStore = useSettingStore()
const apiNodesEnabled = settingStore.get('Comfy.NodeBadge.ShowApiPricing')
const backendUrl = localStorage.getItem('comfyui-preview-backend-url') ?? '—'
const tooltipLines = [
author ? `@${author}` : null,
commitShort ? commitShort : null,
`${t('prPreview.badge.tooltipBackendLabel')}${backendUrl}`,
apiNodesEnabled
? t('prPreview.badge.tooltipCloudApiNote')
: t('prPreview.badge.tooltipCloudApiDisabled')
]
.filter(Boolean)
.join(' · ')
const popoverLinks = [
{ label: `PR #${prNumber}`, url: `${REPO}/pull/${prNumber}` },
...(author
? [{ label: `@${author}`, url: `https://github.com/${author}` }]
: []),
...(commitShort
? [{ label: commitShort, url: `${REPO}/commit/${commit}` }]
: []),
{ label: t('prPreview.badge.configureBackend'), url: '/connect' }
]
const badgeText = commitShort ? `#${prNumber} · ${commitShort}` : `#${prNumber}`
const topbarBadges: TopbarBadge[] = [
{
label: t('prPreview.badge.label'),
text: badgeText,
variant: 'warning',
tooltip: tooltipLines,
popoverLinks
}
]
const aboutPageBadges: AboutPageBadge[] = [
{
label: `PR #${prNumber}`,
url: `${REPO}/pull/${prNumber}`,
icon: 'pi pi-github'
},
...(author
? [
{
label: `@${author}`,
url: `https://github.com/${author}`,
icon: 'pi pi-user'
}
]
: []),
...(commitShort
? [
{
label: commitShort,
url: `${REPO}/commit/${commit}`,
icon: 'pi pi-code'
}
]
: [])
]
useExtensionService().registerExtension({
name: 'Comfy.PrPreview.Badges',
topbarBadges,
aboutPageBadges
})

View File

@@ -3798,5 +3798,72 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"connectionPanel": {
"title": "ComfyUI Frontend Preview",
"subtitle": "Connect to a running ComfyUI backend to use this preview.",
"previewWarningTitle": "⚠ This is a preview build of an in-flight pull request.",
"previewWarningBody": "The UI may change rapidly as the branch is pushed and is under heavy development. Use it for testing and review only — never rely on any *.comfy-ui.pages.dev URL for production work.",
"previewProvenance": "Built from {pr} ({commit}) by {author}.",
"previewUnknownAuthor": "an unknown author",
"previewTrustWarning": "Do not connect a ComfyUI instance you care about unless you trust the author of this PR — a malicious frontend can read and modify any workflow, model, or output on the connected backend.",
"backendUrl": "Backend URL",
"apiKey": "Comfy API Key",
"apiKeyOptional": "(optional)",
"apiKeyPlaceholder": "sk-...",
"apiKeyHint": "Only needed for cloud-API nodes (e.g. Flux, Kling). Saved to your browser.",
"apiKeyTestOk": "API key is valid.",
"apiKeyTestError": "Invalid or expired API key.",
"apiKeyDisabledNotice": "The connected backend was started with --disable-api-nodes; cloud-API nodes are unavailable.",
"test": "Test",
"http": "HTTP",
"ws": "WS",
"status": "Connection Status",
"connected": "Connected — backend is reachable.",
"backendCloud": "Backend cloud API:",
"cloudMismatch": "⚠ Cloud environment mismatch — this preview signs in via {frontend}, so tokens won't be accepted by the backend. Restart the backend with --comfy-api-base={frontend} (or use a frontend build that targets the backend's environment).",
"getApiKeyLink": "→ Generate an API key for this cloud",
"connectAndGo": "Connect & Open ComfyUI",
"quickStart": "Quick Start with Comfy CLI",
"quickStartDescription": "The fastest way to get ComfyUI running locally. No existing Python install required — uv handles it for you.",
"step1InstallUv": "1. Install uv (macOS/Linux, then Windows):",
"uvNote": "uv is a fast Python package manager that auto-installs Python itself, so you don't need Python preinstalled. After install, restart your terminal.",
"step2InstallComfyui": "2. Install comfy-cli and ComfyUI:",
"managerIncludedNote": "This also installs ComfyUI-Manager by default — it makes downloading missing models and custom nodes one-click, so workflows from others just work.",
"managerTitle": "Why ComfyUI-Manager?",
"managerDescription": "ComfyUI-Manager is bundled with comfy install. It auto-detects missing custom nodes and models referenced by any workflow you load, then installs them for you in one click — no more hunting GitHub repos or Hugging Face links by hand.",
"managerLearnMore": "Learn more about ComfyUI-Manager →",
"step3Launch": "3. Launch with CORS enabled:",
"altManualSetup": "Alternative: I already have Python installed",
"altPipDescription": "If you already have Python 3.10+ and pip available, you can install comfy-cli directly:",
"altPipNote": "Note: older Python versions (<3.10) may fail to install some comfy-cli dependencies.",
"altManagerDescription": "If you cloned ComfyUI manually, also install ComfyUI-Manager into custom_nodes/:",
"guideDescription": "If you already have ComfyUI cloned, start it with CORS enabled from the repo root:",
"corsNote": "The --enable-cors-header flag allows this preview page to communicate with your local backend.",
"corsOriginNote": "The exact origin is pre-filled so ComfyUI can allow requests from this specific preview URL. Using * would block requests that include credentials.",
"localAccess": "Local Network Access",
"localAccessDescription": "Your browser may prompt for permission to access local network devices. Allow it so this page can reach your local ComfyUI instance.",
"localAccessListenDescription": "To connect from another device on the same network (e.g. a phone or second computer), pass --listen so ComfyUI binds to all interfaces:",
"localAccessListenNote": "Then enter your machine's LAN IP (e.g. http://192.168.1.x:8188) in the backend URL field above.",
"source": "Source",
"errorUnreachable": "Backend is unreachable. Ensure ComfyUI is running with CORS enabled.",
"errorHttpFailed": "HTTP connection failed. Check the URL and CORS settings.",
"errorWsFailed": "WebSocket connection failed. HTTP works — check firewall or proxy settings.",
"buildPr": "PR #{prNumber}",
"buildVersion": "v{version}",
"tooltipVersion": "Version: {version}",
"tooltipCommit": "Commit: {commit}",
"tooltipBranch": "Branch: {branch}",
"tooltipRunId": "Run ID: {runId}",
"tooltipJobId": "Job ID: {jobId}"
},
"prPreview": {
"badge": {
"label": "PR",
"tooltipBackendLabel": "Backend: ",
"tooltipCloudApiNote": "Cloud API: see Settings → About",
"tooltipCloudApiDisabled": "Cloud API: disabled",
"configureBackend": "Configure backend →"
}
}
}

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isBackendReachable } from './backendReachable'
const STORAGE_KEY = 'comfyui-preview-backend-url'
const mockLocalStorage = vi.hoisted(() => {
const store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
for (const key of Object.keys(store)) delete store[key]
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
_store: store
}
})
vi.stubGlobal('localStorage', mockLocalStorage)
function mockFetchOnce(impl: () => Promise<Response> | Response) {
vi.stubGlobal('fetch', vi.fn(impl))
}
describe('isBackendReachable', () => {
beforeEach(() => {
mockLocalStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.stubGlobal('localStorage', mockLocalStorage)
})
it('returns true when system_stats responds with a system field', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(
() =>
new Response(JSON.stringify({ system: { os: 'darwin' } }), {
status: 200
})
)
expect(await isBackendReachable()).toBe(true)
})
it('returns false when response is not ok', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(() => new Response('Not Found', { status: 404 }))
expect(await isBackendReachable()).toBe(false)
})
it('returns false when response is HTML (no system field)', async () => {
// Simulates a Cloudflare-style SPA fallback returning index.html
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(() => new Response(JSON.stringify({}), { status: 200 }))
expect(await isBackendReachable()).toBe(false)
})
it('returns false when fetch rejects (network error / CORS / aborted)', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188')
mockFetchOnce(() => Promise.reject(new Error('network')))
expect(await isBackendReachable()).toBe(false)
})
it('strips trailing slashes from the configured backend URL', async () => {
mockLocalStorage.setItem(STORAGE_KEY, 'http://127.0.0.1:8188///')
const fetchSpy = vi.fn(
() =>
new Response(JSON.stringify({ system: { os: 'linux' } }), {
status: 200
})
)
vi.stubGlobal('fetch', fetchSpy)
await isBackendReachable()
expect(fetchSpy).toHaveBeenCalledWith(
'http://127.0.0.1:8188/api/system_stats',
expect.any(Object)
)
})
it('falls back to same-origin when no backend URL is configured', async () => {
const fetchSpy = vi.fn(
() =>
new Response(JSON.stringify({ system: { os: 'linux' } }), {
status: 200
})
)
vi.stubGlobal('fetch', fetchSpy)
await isBackendReachable()
expect(fetchSpy).toHaveBeenCalledWith(
'/api/system_stats',
expect.any(Object)
)
})
})

View File

@@ -0,0 +1,47 @@
/**
* Probe the configured ComfyUI backend (local or remote-via-localStorage)
* to confirm it serves the expected `/api/system_stats` shape. Used by the
* router to decide whether to enter GraphView or redirect to /connect.
*/
const BACKEND_URL_KEY = 'comfyui-preview-backend-url'
const PROBE_TIMEOUT_MS = 3000
function resolveProbeBase(): string {
const stored = localStorage.getItem(BACKEND_URL_KEY)
if (stored) {
try {
// Only treat the stored value as a backend override when it's a
// well-formed absolute URL — otherwise fall through to same-origin.
const url = new URL(stored)
return url.origin + url.pathname.replace(/\/+$/, '')
} catch {
// Ignore malformed entries; same-origin probe is safer than a
// relative URL that misses the router's subpath base.
}
}
// Mirror ComfyApi's same-origin base so subpath deployments probe the
// backend that would actually serve the app.
if (typeof window === 'undefined') return ''
return window.location.pathname.split('/').slice(0, -1).join('/')
}
export async function isBackendReachable(): Promise<boolean> {
const apiBase = resolveProbeBase()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
const res = await fetch(`${apiBase}/api/system_stats`, {
signal: controller.signal
})
if (!res.ok) return false
const body = (await res.json()) as { system?: unknown }
return !!body.system
} catch {
return false
} finally {
clearTimeout(timeout)
}
}

View File

@@ -0,0 +1,24 @@
const COMFY_API_BASE_FLAG = '--comfy-api-base'
const DEFAULT_CLOUD_API_BASE = 'https://api.comfy.org'
type SystemInfo = { argv?: string[]; comfy_api_base?: string }
function parseArgvApiBase(argv: string[] | undefined): string | undefined {
if (!argv) return undefined
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === COMFY_API_BASE_FLAG && i + 1 < argv.length) return argv[i + 1]
if (a.startsWith(`${COMFY_API_BASE_FLAG}=`))
return a.slice(COMFY_API_BASE_FLAG.length + 1)
}
return undefined
}
export function resolveBackendCloudBase(
system: SystemInfo | undefined
): string {
const explicit = system?.comfy_api_base
if (explicit) return explicit.replace(/\/+$/, '')
const fromArgv = parseArgvApiBase(system?.argv)
return (fromArgv ?? DEFAULT_CLOUD_API_BASE).replace(/\/+$/, '')
}

View File

@@ -8,6 +8,7 @@ import {
import type { RouteLocationNormalized } from 'vue-router'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isBackendReachable } from '@/platform/connectionPanel/backendReachable'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -29,13 +30,17 @@ const isFileProtocol = window.location.protocol === 'file:'
* Determine base path for the router.
* - Electron: always root
* - Cloud: use Vite's BASE_URL (configured at build time)
* - Standard web (including reverse proxy subpaths): use window.location.pathname
* to support deployments like http://mysite.com/ComfyUI/
* - Standard web: a deploy directory pathname ends with `/`
* (e.g. `/ComfyUI/`) — use it as base to support reverse-proxy subpaths.
* A SPA route pathname does not end with `/` (e.g. `/connect`) — fall back
* to BASE_URL so the route doesn't get appended to itself.
*/
function getBasePath(): string {
if (isDesktop) return '/'
if (isCloud) return import.meta.env?.BASE_URL || '/'
return window.location.pathname
const pathname = window.location.pathname
if (pathname.endsWith('/')) return pathname
return import.meta.env?.BASE_URL || '/'
}
const basePath = getBasePath()
@@ -66,6 +71,12 @@ const router = createRouter({
name: 'GraphView',
component: () => import('@/views/GraphView.vue'),
beforeEnter: async (_to, _from, next) => {
// Redirect to /connect when no ComfyUI backend is reachable
// (e.g. static deployments like Cloudflare Pages preview)
if (!(await isBackendReachable())) {
return next('/connect')
}
// Then check user store
const userStore = useUserStore()
await userStore.initialize()
@@ -82,6 +93,11 @@ const router = createRouter({
component: () => import('@/views/UserSelectView.vue')
}
]
},
{
path: '/connect',
name: 'ConnectionPanel',
component: () => import('@/views/ConnectionPanelView.vue')
}
],

View File

@@ -247,6 +247,7 @@ const zSystemStats = z.object({
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),
comfy_api_base: z.string().optional(),
ram_total: z.number(),
ram_free: z.number(),
// Cloud-specific fields

View File

@@ -367,27 +367,52 @@ export class ComfyApi extends EventTarget {
*/
apiKey?: string
/**
* The origin (protocol + host) for the backend, when overridden via the
* preview connection panel. Empty string means use same-origin.
*/
private remoteOrigin = ''
constructor() {
super()
this.user = ''
this.api_host = location.host
this.api_base = isCloud
? ''
: location.pathname.split('/').slice(0, -1).join('/')
const remoteBackend = localStorage.getItem('comfyui-preview-backend-url')
let parsedRemote: URL | null = null
if (remoteBackend) {
try {
parsedRemote = new URL(remoteBackend)
} catch {
// Corrupt value would crash the app at startup; drop it and fall back.
localStorage.removeItem('comfyui-preview-backend-url')
}
}
if (parsedRemote) {
this.remoteOrigin = parsedRemote.origin
this.api_host = parsedRemote.host
this.api_base = parsedRemote.pathname.replace(/\/+$/, '')
} else {
this.api_host = location.host
this.api_base = isCloud
? ''
: location.pathname.split('/').slice(0, -1).join('/')
}
this.initialClientId = sessionStorage.getItem('clientId')
}
internalURL(route: string): string {
return this.api_base + '/internal' + route
return this.remoteOrigin + this.api_base + '/internal' + route
}
apiURL(route: string): string {
if (route.startsWith('/api')) return this.api_base + route
return this.api_base + '/api' + route
if (route.startsWith('/api'))
return this.remoteOrigin + this.api_base + route
return this.remoteOrigin + this.api_base + '/api' + route
}
fileURL(route: string): string {
return this.api_base + route
return this.remoteOrigin + this.api_base + route
}
/**
@@ -578,8 +603,14 @@ export class ComfyApi extends EventTarget {
}
}
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const baseUrl = `${protocol}://${this.api_host}${this.api_base}/ws`
// Derive WebSocket protocol from remote backend if set, else from page
let wsProtocol: string
if (this.remoteOrigin) {
wsProtocol = this.remoteOrigin.startsWith('https:') ? 'wss' : 'ws'
} else {
wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
}
const baseUrl = `${wsProtocol}://${this.api_host}${this.api_base}/ws`
const query = params.toString()
const wsUrl = query ? `${baseUrl}?${query}` : baseUrl

View File

@@ -115,7 +115,15 @@ export const defaultGraph: ComfyWorkflowJSON = {
{ 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

@@ -57,6 +57,12 @@ export interface TopbarBadge {
* Optional tooltip text to show on hover
*/
tooltip?: string
/**
* Optional links rendered as clickable anchors inside the popover.
* External URLs (starting with "http") open in a new tab; internal
* paths (e.g. "/connect") navigate within the SPA.
*/
popoverLinks?: Array<{ label: string; url: string }>
}
/*

View File

@@ -0,0 +1,286 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ConnectionPanelView from './ConnectionPanelView.vue'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({ changeTheme: vi.fn() })),
isNativeWindow: vi.fn(() => false)
}))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: false,
isCloud: false,
isNightly: false
}))
vi.mock('vue-router', () => ({
useRouter: () => ({
push: vi.fn()
})
}))
const mockLocalStorage = vi.hoisted(() => {
const store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
for (const key of Object.keys(store)) delete store[key]
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
_store: store
}
})
vi.stubGlobal('localStorage', mockLocalStorage)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
function renderPanel() {
return render(ConnectionPanelView, {
global: {
plugins: [i18n]
}
})
}
describe('ConnectionPanelView', () => {
beforeEach(() => {
mockLocalStorage.clear()
vi.restoreAllMocks()
vi.stubGlobal('localStorage', mockLocalStorage)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders the backend URL input with default value', () => {
renderPanel()
const input = screen.getByDisplayValue(
'http://127.0.0.1:8188'
) as HTMLInputElement
expect(input).toBeTruthy()
})
it('loads backend URL from localStorage', () => {
mockLocalStorage.setItem(
'comfyui-preview-backend-url',
'http://192.168.1.100:8188'
)
renderPanel()
const input = screen.getByDisplayValue(
'http://192.168.1.100:8188'
) as HTMLInputElement
expect(input).toBeTruthy()
})
it('shows test button', () => {
renderPanel()
expect(screen.getByRole('button', { name: /test/i })).toBeTruthy()
})
it('displays the comfy-cli install command', () => {
renderPanel()
expect(screen.getByText('pip install comfy-cli')).toBeTruthy()
})
it('displays the comfy launch command', () => {
renderPanel()
expect(
screen.getByText(
`comfy launch -- --enable-cors-header="${window.location.origin}"`
)
).toBeTruthy()
})
it('displays the local network access section', () => {
renderPanel()
expect(
screen.getByRole('heading', { level: 2, name: /local/i })
).toBeTruthy()
})
it('saves URL to localStorage on test', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
renderPanel()
const user = userEvent.setup()
const input = screen.getByDisplayValue(
'http://127.0.0.1:8188'
) as HTMLInputElement
await user.clear(input)
await user.type(input, 'http://10.0.0.1:8188')
const testButton = screen.getByRole('button', { name: /test/i })
await user.click(testButton)
await vi.waitFor(() => {
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'comfyui-preview-backend-url',
'http://10.0.0.1:8188'
)
})
})
it('shows red HTTP indicator when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
// Stub WebSocket to never open so wsStatus also resolves to false
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'error') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
// i18n in tests is empty so the status text falls back to the key
expect(screen.getByText(/connectionPanel\.error/)).toBeTruthy()
})
})
it('normalizes a URL without protocol by prepending http://', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
renderPanel()
const user = userEvent.setup()
const input = screen.getByDisplayValue(
'http://127.0.0.1:8188'
) as HTMLInputElement
await user.clear(input)
await user.type(input, '192.168.1.50:8188')
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'comfyui-preview-backend-url',
'http://192.168.1.50:8188'
)
})
})
it('links to staging platform when backend uses staging cloud base', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
system: {
argv: [
'main.py',
'--comfy-api-base=https://stagingapi.comfy.org'
]
}
})
} as Response)
)
)
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'open') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
const link = screen.getByRole('link', {
name: 'connectionPanel.getApiKeyLink'
})
expect(link.getAttribute('href')).toBe(
'https://stagingplatform.comfy.org/profile/api-keys'
)
})
})
it('parses backend cloud API base from system_stats argv', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
system: {
argv: [
'main.py',
'--enable-cors-header=*',
'--comfy-api-base',
'https://stagingapi.comfy.org'
]
}
})
} as Response)
)
)
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'open') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
expect(screen.getByText('https://stagingapi.comfy.org')).toBeTruthy()
})
})
it('reveals Connect & Open ComfyUI button after a successful HTTP test', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ system: { argv: [] } })
} as Response)
)
)
class StubWS {
addEventListener(type: string, cb: () => void) {
if (type === 'open') setTimeout(cb, 0)
}
close() {}
}
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
renderPanel()
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /test/i }))
await vi.waitFor(() => {
// i18n in tests is empty so the button label falls back to the key
expect(screen.getByText('connectionPanel.connectAndGo')).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,598 @@
<template>
<BaseViewTemplate dark>
<main
class="relative my-8 flex w-full max-w-lg flex-col gap-6 rounded-lg bg-(--comfy-menu-bg) p-8 shadow-lg"
>
<header class="flex flex-col gap-2">
<h1 class="text-xl font-semibold text-neutral-100">
{{ t('connectionPanel.title') }}
</h1>
<p class="text-sm text-neutral-400">
{{ t('connectionPanel.subtitle') }}
</p>
<aside
v-if="prNumber"
class="mt-1 flex flex-col gap-1 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200"
>
<p class="font-medium">
{{ t('connectionPanel.previewWarningTitle') }}
</p>
<p class="text-amber-200/85">
{{ t('connectionPanel.previewWarningBody') }}
</p>
<i18n-t
keypath="connectionPanel.previewProvenance"
tag="p"
class="text-amber-200/85"
>
<template #pr>
<a
:href="prUrl"
target="_blank"
rel="noopener"
class="underline hover:text-amber-100"
>#{{ prNumber }}</a
>
</template>
<template #commit>
<a
:href="commitUrl"
target="_blank"
rel="noopener"
class="underline hover:text-amber-100"
><code>{{ commitShort }}</code></a
>
</template>
<template #author>
<a
v-if="prAuthor"
:href="authorUrl"
target="_blank"
rel="noopener"
class="underline hover:text-amber-100"
>@{{ prAuthor }}</a
>
<span v-else>{{
t('connectionPanel.previewUnknownAuthor')
}}</span>
</template>
</i18n-t>
<p class="font-medium text-amber-100">
{{ t('connectionPanel.previewTrustWarning') }}
</p>
</aside>
</header>
<!-- Backend URL input -->
<section class="flex flex-col gap-2">
<label for="backend-url" class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.backendUrl') }}
</label>
<div class="flex gap-2">
<input
id="backend-url"
v-model="backendUrl"
type="url"
:placeholder="DEFAULT_BACKEND_URL"
class="flex h-10 w-full min-w-0 appearance-none rounded-lg border-none bg-neutral-800 px-4 py-2 text-sm text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
@keyup.enter="testConnection"
/>
<Button
variant="primary"
size="lg"
:loading="isTesting"
:disabled="isTesting"
@click="testConnection"
>
{{ t('connectionPanel.test') }}
</Button>
</div>
</section>
<!-- Connection status -->
<section
v-if="httpStatus !== null || wsStatus !== null"
role="status"
aria-live="polite"
class="flex flex-col gap-2 rounded-md bg-neutral-800/50 p-3"
>
<h2
class="text-xs font-medium tracking-wide text-neutral-400 uppercase"
>
{{ t('connectionPanel.status') }}
</h2>
<div class="flex gap-4 text-sm">
<span class="flex items-center gap-1.5">
<span
:class="
cn(
'inline-block size-2 rounded-full',
httpStatus === true && 'bg-green-500',
httpStatus === false && 'bg-red-500',
httpStatus === null && 'bg-neutral-600'
)
"
/>
{{ t('connectionPanel.http') }}
{{ httpStatus === true ? '✓' : httpStatus === false ? '✗' : '—' }}
</span>
<span class="flex items-center gap-1.5">
<span
:class="
cn(
'inline-block size-2 rounded-full',
wsStatus === true && 'bg-green-500',
wsStatus === false && 'bg-red-500',
wsStatus === null && 'bg-neutral-600'
)
"
/>
{{ t('connectionPanel.ws') }}
{{ wsStatus === true ? '✓' : wsStatus === false ? '✗' : '—' }}
</span>
</div>
<p v-if="connectionError" class="text-xs text-red-400">
{{ connectionError }}
</p>
<p
v-if="httpStatus === true && wsStatus === true"
class="text-xs text-green-400"
>
{{ t('connectionPanel.connected') }}
</p>
<!-- Backend cloud-API base + API key -->
<div
v-if="backendCloudBase"
class="flex flex-col gap-3 border-t border-neutral-700 pt-2"
>
<p class="text-xs text-neutral-400">
<span class="text-neutral-500"
>{{ t('connectionPanel.backendCloud') }}
</span>
<code
class="ml-1 rounded-sm bg-neutral-900 px-1 py-0.5 text-neutral-200"
>{{ backendCloudBase }}</code
>
</p>
<p v-if="cloudMismatch" class="text-xs text-amber-400">
{{
t('connectionPanel.cloudMismatch', {
frontend: frontendCloudBase
})
}}
</p>
<!-- API key input hidden when --disable-api-nodes -->
<div v-if="!isApiNodeDisabled" class="flex flex-col gap-1.5">
<label for="api-key" class="text-xs font-medium text-neutral-300">
{{ t('connectionPanel.apiKey') }}
<span class="ml-1 font-normal text-neutral-500">{{
t('connectionPanel.apiKeyOptional')
}}</span>
</label>
<div class="flex gap-2">
<input
id="api-key"
v-model="apiKeyInput"
type="password"
:placeholder="t('connectionPanel.apiKeyPlaceholder')"
autocomplete="current-password"
class="flex h-8 w-full min-w-0 appearance-none rounded-md border-none bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
@keyup.enter="testApiKey"
/>
<Button
variant="secondary"
size="sm"
:loading="isTestingApiKey"
:disabled="isTestingApiKey || !apiKeyInput.trim()"
@click="testApiKey"
>
{{ t('connectionPanel.test') }}
</Button>
</div>
<p v-if="apiKeyStatus === 'ok'" class="text-xs text-green-400">
{{ t('connectionPanel.apiKeyTestOk') }}
</p>
<p
v-else-if="apiKeyStatus === 'error'"
class="text-xs text-red-400"
>
{{ t('connectionPanel.apiKeyTestError') }}
</p>
<p v-else class="text-xs text-neutral-500">
{{ t('connectionPanel.apiKeyHint') }}
<a
v-if="apiKeyPageUrl"
:href="apiKeyPageUrl"
target="_blank"
rel="noopener"
class="ml-1 text-neutral-400 underline decoration-dotted hover:text-neutral-200"
>
{{ t('connectionPanel.getApiKeyLink') }}
</a>
</p>
</div>
<p v-else class="text-xs text-neutral-500">
{{ t('connectionPanel.apiKeyDisabledNotice') }}
</p>
</div>
<!-- Connect & Go button -->
<Button
v-if="httpStatus === true"
variant="primary"
size="lg"
class="mt-2 w-full"
@click="connectAndGo"
>
{{ t('connectionPanel.connectAndGo') }}
</Button>
</section>
<!-- Quick Start with Comfy CLI -->
<section class="flex flex-col gap-3">
<h2 class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.quickStart') }}
</h2>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.quickStartDescription') }}
</p>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step1InstallUv') }}
</span>
<CopyCodeBlock
text="curl -LsSf https://astral.sh/uv/install.sh | sh"
/>
<CopyCodeBlock
text='powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
/>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.uvNote') }}
</p>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step2InstallComfyui') }}
</span>
<CopyCodeBlock
text="uv pip install comfy-cli --system && comfy install"
/>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.managerIncludedNote') }}
</p>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step3Launch') }}
</span>
<CopyCodeBlock :text="launchCmd" />
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.corsOriginNote') }}
</p>
</div>
</div>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.corsNote') }}
</p>
<aside
class="flex flex-col gap-1 rounded-md border border-neutral-700 bg-neutral-800/50 p-3"
>
<h3 class="text-xs font-medium text-neutral-300">
{{ t('connectionPanel.managerTitle') }}
</h3>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.managerDescription') }}
</p>
<a
href="https://github.com/Comfy-Org/ComfyUI-Manager"
target="_blank"
rel="noopener"
class="text-xs text-neutral-300 underline hover:text-neutral-100"
>
{{ t('connectionPanel.managerLearnMore') }}
</a>
</aside>
</section>
<!-- Alternative: manual python / pip -->
<details class="group">
<summary
class="cursor-pointer text-sm font-medium text-neutral-400 hover:text-neutral-300"
>
{{ t('connectionPanel.altManualSetup') }}
</summary>
<div class="mt-2 flex flex-col gap-3">
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.altPipDescription') }}
</p>
<CopyCodeBlock text="pip install comfy-cli" />
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.altPipNote') }}
</p>
</div>
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.altManagerDescription') }}
</p>
<CopyCodeBlock
text="git clone https://github.com/Comfy-Org/ComfyUI-Manager.git custom_nodes/ComfyUI-Manager"
/>
</div>
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.guideDescription') }}
</p>
<CopyCodeBlock :text="pythonMainCmd" />
</div>
</div>
</details>
<!-- Local network access -->
<section class="flex flex-col gap-3">
<h2 class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.localAccess') }}
</h2>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.localAccessDescription') }}
</p>
<div class="flex flex-col gap-1">
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.localAccessListenDescription') }}
</p>
<CopyCodeBlock :text="launchListenCmd" />
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.localAccessListenNote') }}
</p>
</div>
</section>
<footer
class="flex items-center justify-between border-t border-neutral-700 pt-4 text-xs text-neutral-500"
>
<span
:title="buildTooltip"
class="cursor-help underline decoration-dotted"
>
{{ buildLabel }}
</span>
<a
:href="repoUrl"
target="_blank"
rel="noopener"
class="text-neutral-400 hover:text-neutral-200"
>
{{ t('connectionPanel.source') }}
</a>
</footer>
</main>
</BaseViewTemplate>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import CopyCodeBlock from '@/components/connection/CopyCodeBlock.vue'
import { cn } from '@comfyorg/tailwind-utils'
import {
getComfyApiBaseUrl,
getPlatformBaseUrlForApiBase
} from '@/config/comfyApi'
import { resolveBackendCloudBase } from '@/platform/connectionPanel/resolveBackendCloudBase'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
type SystemStats = {
system?: { argv?: string[]; comfy_api_base?: string }
}
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, '')
}
const { t } = useI18n()
const DEFAULT_BACKEND_URL = 'http://127.0.0.1:8188'
const STORAGE_KEY = 'comfyui-preview-backend-url'
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
const corsOrigin = window.location.origin
const backendUrl = ref(localStorage.getItem(STORAGE_KEY) || DEFAULT_BACKEND_URL)
const API_KEY_STORAGE_KEY = 'comfy_api_key'
const apiKeyInput = ref(localStorage.getItem(API_KEY_STORAGE_KEY) ?? '')
const launchCmd = `comfy launch -- --enable-cors-header="${corsOrigin}"`
const launchListenCmd = `comfy launch -- --listen --enable-cors-header="${corsOrigin}"`
const pythonMainCmd = `python main.py --enable-cors-header="${corsOrigin}"`
const isTesting = ref(false)
const httpStatus = ref<boolean | null>(null)
const wsStatus = ref<boolean | null>(null)
const connectionError = ref('')
const backendCloudBase = ref<string | null>(null)
const isApiNodeDisabled = ref(false)
const isTestingApiKey = ref(false)
const apiKeyStatus = ref<'idle' | 'ok' | 'error'>('idle')
const frontendCloudBase = stripTrailingSlash(getComfyApiBaseUrl())
const cloudMismatch = computed(
() =>
backendCloudBase.value !== null &&
backendCloudBase.value !== frontendCloudBase
)
const apiKeyPageUrl = computed(() => {
if (!backendCloudBase.value) return null
const platform = getPlatformBaseUrlForApiBase(backendCloudBase.value)
return platform ? `${platform}/profile/api-keys` : null
})
function normalizeUrl(raw: string): string {
let url = raw.trim()
if (!url) url = DEFAULT_BACKEND_URL
if (!/^https?:\/\//i.test(url)) url = 'http://' + url
return url.replace(/\/+$/, '')
}
async function fetchSystemStats(base: string): Promise<SystemStats | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch(`${base}/api/system_stats`, {
signal: controller.signal
})
if (!res.ok) return null
return (await res.json()) as SystemStats
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
function testWs(base: string): Promise<boolean> {
return new Promise((resolve) => {
const wsUrl = new URL(base)
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl.pathname = '/ws'
wsUrl.search = ''
wsUrl.hash = ''
const ws = new WebSocket(wsUrl.toString())
const timeout = setTimeout(() => {
ws.close()
resolve(false)
}, 5000)
ws.addEventListener('open', () => {
clearTimeout(timeout)
ws.close()
resolve(true)
})
ws.addEventListener('error', () => {
clearTimeout(timeout)
resolve(false)
})
})
}
async function testConnection() {
isTesting.value = true
httpStatus.value = null
wsStatus.value = null
connectionError.value = ''
backendCloudBase.value = null
const base = normalizeUrl(backendUrl.value)
backendUrl.value = base
localStorage.setItem(STORAGE_KEY, base)
try {
const [stats, ws] = await Promise.all([
fetchSystemStats(base),
testWs(base)
])
httpStatus.value = stats !== null
wsStatus.value = ws
backendCloudBase.value = stats
? resolveBackendCloudBase(stats.system)
: null
isApiNodeDisabled.value =
stats?.system?.argv?.includes('--disable-api-nodes') ?? false
if (stats === null && !ws) {
connectionError.value = t('connectionPanel.errorUnreachable')
} else if (stats === null) {
connectionError.value = t('connectionPanel.errorHttpFailed')
} else if (!ws) {
connectionError.value = t('connectionPanel.errorWsFailed')
}
} catch {
httpStatus.value = false
wsStatus.value = false
connectionError.value = t('connectionPanel.errorUnreachable')
} finally {
isTesting.value = false
}
}
async function testApiKey() {
const key = apiKeyInput.value.trim()
if (!key) return
isTestingApiKey.value = true
apiKeyStatus.value = 'idle'
const base = backendCloudBase.value ?? frontendCloudBase
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
try {
const res = await fetch(`${base}/customers`, {
method: 'POST',
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
signal: controller.signal
})
apiKeyStatus.value = res.ok ? 'ok' : 'error'
} catch {
apiKeyStatus.value = 'error'
} finally {
clearTimeout(timeout)
isTestingApiKey.value = false
}
}
function connectAndGo() {
const base = normalizeUrl(backendUrl.value)
localStorage.setItem(STORAGE_KEY, base)
const trimmedKey = apiKeyInput.value.trim()
if (trimmedKey) {
localStorage.setItem(API_KEY_STORAGE_KEY, trimmedKey)
}
// Full page reload so ComfyApi constructor picks up the new backend URL
window.location.href = import.meta.env.BASE_URL || '/'
}
const version = __COMFYUI_FRONTEND_VERSION__
const commit = __COMFYUI_FRONTEND_COMMIT__
const branch = __CI_BRANCH__
const prNumber = __CI_PR_NUMBER__
const prAuthor = __CI_PR_AUTHOR__
const runId = __CI_RUN_ID__
const jobId = __CI_JOB_ID__
const commitShort = commit ? commit.slice(0, 8) : ''
const prUrl = prNumber ? `${REPO}/pull/${prNumber}` : REPO
const commitUrl = commit ? `${REPO}/commit/${commit}` : REPO
const authorUrl = prAuthor ? `https://github.com/${prAuthor}` : ''
const buildLabel = computed(() => {
if (prNumber) return t('connectionPanel.buildPr', { prNumber })
if (branch) return branch
return t('connectionPanel.buildVersion', { version })
})
const buildTooltip = computed(() => {
const parts = [t('connectionPanel.tooltipVersion', { version })]
if (commit)
parts.push(
t('connectionPanel.tooltipCommit', { commit: commit.slice(0, 8) })
)
if (branch) parts.push(t('connectionPanel.tooltipBranch', { branch }))
if (runId) parts.push(t('connectionPanel.tooltipRunId', { runId }))
if (jobId) parts.push(t('connectionPanel.tooltipJobId', { jobId }))
return parts.join('\n')
})
const repoUrl = computed(() => {
if (prNumber) return `${REPO}/pull/${prNumber}`
if (branch) return `${REPO}/tree/${branch}`
return REPO
})
onMounted(() => {
document.getElementById('splash-loader')?.remove()
})
</script>

View File

@@ -13,7 +13,7 @@
ref="topMenuRef"
class="app-drag h-(--comfy-topbar-height) w-full"
/>
<div class="flex w-full grow items-center justify-center overflow-auto">
<div class="grid w-full grow place-items-center overflow-auto">
<slot />
</div>
</div>

7
src/vite-env.d.ts vendored
View File

@@ -17,6 +17,13 @@ declare global {
__COMFYUI_FRONTEND_VERSION__: string
}
const __COMFYUI_FRONTEND_COMMIT__: string
const __CI_BRANCH__: string
const __CI_PR_NUMBER__: string
const __CI_PR_AUTHOR__: string
const __CI_RUN_ID__: string
const __CI_JOB_ID__: string
interface ImportMetaEnv {
VITE_APP_VERSION?: string
}

View File

@@ -626,7 +626,12 @@ export default defineConfig({
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY)
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY),
__CI_BRANCH__: JSON.stringify(process.env.CI_BRANCH || ''),
__CI_PR_NUMBER__: JSON.stringify(process.env.CI_PR_NUMBER || ''),
__CI_PR_AUTHOR__: JSON.stringify(process.env.CI_PR_AUTHOR || ''),
__CI_RUN_ID__: JSON.stringify(process.env.CI_RUN_ID || ''),
__CI_JOB_ID__: JSON.stringify(process.env.CI_JOB_ID || '')
},
resolve: {