Compare commits

...

22 Commits

Author SHA1 Message Date
snomiao
3c011360fa 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-14 00:26:03 +09:00
snomiao
30ce606b0a 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-14 00:25:11 +09:00
snomiao
4764a0850c 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-14 00:24:22 +09:00
snomiao
b941018b44 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-14 00:24:00 +09:00
snomiao
081bf02e0f 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-14 00:23:44 +09:00
snomiao
70707af9d9 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-14 00:23:21 +09:00
snomiao
7bda43a269 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-14 00:21:37 +09:00
snomiao
fddd02d778 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-14 00:21:25 +09:00
snomiao
8fd6531c57 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-14 00:20:49 +09:00
snomiao
5f47cc7997 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-12 12:21:03 +09:00
snomiao
9e2934d1ae 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-12 10:44:00 +09:00
snomiao
caabc29207 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-12 10:00:17 +09:00
snomiao
00b47b52ef 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-12 09:01:07 +09:00
snomiao
6c371bedfa 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-12 03:10:16 +09:00
snomiao
e2156dbe2a 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-12 03:06:19 +09:00
snomiao
f7f80616e0 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-12 02:11:02 +09:00
snomiao
970194e2be 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-12 02:05:48 +09:00
snomiao
0415a105b9 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-11 23:45:03 +09:00
snomiao
cdcdb9aac2 Merge remote-tracking branch 'origin/main' into sno-frontend-preview 2026-04-11 23:39:20 +09:00
snomiao
24b9f171a4 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-11 23:36:03 +09:00
snomiao
b4f8605ab2 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-11 04:15:33 +09:00
snomiao
0d5ced4686 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-10 15:58:32 +00:00
16 changed files with 1258 additions and 19 deletions

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

@@ -0,0 +1,138 @@
# 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 }}" \
"${{ github.head_ref }}" \
"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_RUN_ID: ${{ github.run_id }}
CI_JOB_ID: ${{ github.job }}
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: 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 }}
run: |
./scripts/cicd/pr-preview-deploy-and-comment.sh \
"${{ github.event.pull_request.number }}" \
"${{ github.head_ref }}" \
"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: |
npx wrangler@^4.0.0 pages deploy dist \
--project-name=comfy-ui \
--branch=main

63
TODO.md Normal file
View File

@@ -0,0 +1,63 @@
# ComfyUI Frontend Preview — Cloudflare Pages Deployment
Deploy the ComfyUI frontend to `comfy-ui.pages.dev` with a connection panel
for users to connect to their local (or remote) ComfyUI backend.
---
## Plan
### Phase 1: Build Metadata & CI Pipeline
- [x] Plan: track everything in `TODO.md` (bujo)
- [x] `global.d.ts`: declare new CI build-time constants
- [x] `eslint.config.ts`: register CI globals as `readonly`
- [x] `vite.config.mts`: inject CI env vars (`__CI_BRANCH__`, `__CI_PR_NUMBER__`, `__CI_RUN_ID__`, `__CI_JOB_ID__`)
- [x] `.github/workflows/ci-deploy-preview.yaml`: build + deploy to CF Pages on every push/PR
- [x] `scripts/cicd/pr-preview-deploy-and-comment.sh`: deploy script + PR comment (follows storybook pattern)
### Phase 2: ConnectionPanel Component
- [x] `src/views/ConnectionPanelView.vue`: standalone connection/setup page
- [x] Build info badge (PR/branch, commit SHA tooltip, job ID, action ID, base version from `package.json`)
- [x] Backend URL input (default `http://127.0.0.1:8188/`, saved in localStorage)
- [x] Test button: HTTP (`GET /api/system_stats`) + WebSocket (`ws://host/ws`)
- [x] Connection status indicators (HTTP ✓/✗, WS ✓/✗)
- [x] Command-line guide to run ComfyUI with CORS enabled
- [x] Local network permission guidance
- [x] `src/locales/en/main.json`: i18n strings for connection panel
### Phase 3: Router Integration
- [x] `src/router.ts`: add `/connect` route for ConnectionPanelView
### Phase 4: Tests
- [x] `src/views/ConnectionPanelView.test.ts`: unit tests (8/8 passing)
### Phase 5: Verification
- [x] `pnpm typecheck` — ✅ passed
- [x] `pnpm lint` — ✅ passed
- [x] `pnpm test:unit -- src/views/ConnectionPanelView.test.ts` — ✅ 8/8 passed
---
## Files Changed/Created
| File | Action |
| ----------------------------------------------- | ------------------------------------------ |
| `TODO.md` | created |
| `.github/workflows/ci-deploy-preview.yaml` | created |
| `scripts/cicd/pr-preview-deploy-and-comment.sh` | created |
| `src/views/ConnectionPanelView.vue` | created |
| `src/views/ConnectionPanelView.test.ts` | created |
| `src/locales/en/main.json` | modified (added `connectionPanel` section) |
| `src/router.ts` | modified (added `/connect` route) |
| `global.d.ts` | modified (added `__CI_*` declarations) |
| `vite.config.mts` | modified (added `__CI_*` defines) |
| `eslint.config.ts` | modified (added `__CI_*` globals) |
## Log
- `[2026-04-10]` Created plan, implemented all phases, all checks passing

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

@@ -27,7 +27,11 @@ 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_RUN_ID__: 'readonly',
__CI_JOB_ID__: 'readonly'
} as const
const settings = {

4
global.d.ts vendored
View File

@@ -2,6 +2,10 @@ 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_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,212 @@
#!/bin/bash
set -e
# Deploy frontend preview to Cloudflare Pages and comment on PR
# Usage: ./pr-preview-deploy-and-comment.sh <pr_number> <branch_name> <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"
# Sanitize and validate branch name (allow alphanumeric, dots, dashes, underscores, slashes)
BRANCH_NAME=$(echo "$2" | sed 's/[^a-zA-Z0-9._/-]//g')
if [ -z "$BRANCH_NAME" ]; then
echo "Error: Invalid or empty branch name" >&2
exit 1
fi
# Validate status parameter
STATUS="${3:-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 -->"
# Install wrangler if not available (output to stderr for debugging)
if ! command -v wrangler > /dev/null 2>&1; then
echo "Installing wrangler v4..." >&2
npm install -g wrangler@^4.0.0 >&2 || {
echo "Failed to install wrangler" >&2
echo "failed"
exit 1
}
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
# Use PR-scoped alias to avoid branch-name collisions
cloudflare_branch="pr-$PR_NUMBER"
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

@@ -3754,5 +3754,41 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"connectionPanel": {
"title": "ComfyUI Frontend Preview",
"subtitle": "Connect to a running ComfyUI backend to use this preview.",
"backendUrl": "Backend URL",
"test": "Test",
"http": "HTTP",
"ws": "WS",
"status": "Connection Status",
"connected": "Connected — backend is reachable.",
"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.",
"step2InstallComfyCli": "2. Install comfy-cli:",
"step3InstallComfyui": "3. Install ComfyUI:",
"step4Launch": "4. 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.",
"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.",
"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.",
"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}"
}
}

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,29 @@
/**
* 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
export async function isBackendReachable(): Promise<boolean> {
const backendUrl = localStorage.getItem(BACKEND_URL_KEY) || ''
const apiBase = backendUrl.replace(/\/+$/, '')
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

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

@@ -367,27 +367,44 @@ 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')
if (remoteBackend) {
const url = new URL(remoteBackend)
this.remoteOrigin = url.origin
this.api_host = url.host
this.api_base = url.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 +595,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

@@ -0,0 +1,202 @@
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
}))
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('reveals Connect & Open ComfyUI button after a successful HTTP test', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true } 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,378 @@
<template>
<BaseViewTemplate dark>
<main
class="relative 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-1">
<h1 class="text-xl font-semibold text-neutral-100">
{{ t('connectionPanel.title') }}
</h1>
<p class="text-sm text-neutral-400">
{{ t('connectionPanel.subtitle') }}
</p>
</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>
<!-- 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>
<code
class="block rounded-md bg-neutral-800 p-3 text-xs text-neutral-200 select-all"
>
curl -LsSf https://astral.sh/uv/install.sh | sh
</code>
<code
class="block rounded-md bg-neutral-800 p-3 text-xs text-neutral-200 select-all"
>
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
</code>
<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.step2InstallComfyCli') }}
</span>
<code
class="block rounded-md bg-neutral-800 p-3 text-xs text-neutral-200 select-all"
>
uv pip install comfy-cli --system
</code>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step3InstallComfyui') }}
</span>
<code
class="block rounded-md bg-neutral-800 p-3 text-xs text-neutral-200 select-all"
>
comfy install
</code>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-neutral-400">
{{ t('connectionPanel.step4Launch') }}
</span>
<code
class="block rounded-md bg-neutral-800 p-3 text-xs text-neutral-200 select-all"
>
comfy launch -- --enable-cors-header="{{ corsOrigin }}"
</code>
</div>
</div>
<p class="text-xs text-neutral-500">
{{ t('connectionPanel.corsNote') }}
</p>
</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>
<code
class="block rounded-md bg-neutral-800 p-3 text-xs text-neutral-200 select-all"
>
pip install comfy-cli
</code>
<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.guideDescription') }}
</p>
<code
class="block rounded-md bg-neutral-800 p-3 text-xs text-neutral-200 select-all"
>
python main.py --enable-cors-header="{{ corsOrigin }}"
</code>
</div>
</div>
</details>
<!-- Local network access -->
<section class="flex flex-col gap-2">
<h2 class="text-sm font-medium text-neutral-300">
{{ t('connectionPanel.localAccess') }}
</h2>
<p class="text-xs text-neutral-400">
{{ t('connectionPanel.localAccessDescription') }}
</p>
</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 { cn } from '@/utils/tailwindUtil'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
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 isTesting = ref(false)
const httpStatus = ref<boolean | null>(null)
const wsStatus = ref<boolean | null>(null)
const connectionError = ref('')
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 testHttp(base: string): Promise<boolean> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch(`${base}/api/system_stats`, {
signal: controller.signal
})
return res.ok
} catch {
return false
} 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 = ''
const base = normalizeUrl(backendUrl.value)
backendUrl.value = base
localStorage.setItem(STORAGE_KEY, base)
try {
const [http, ws] = await Promise.all([testHttp(base), testWs(base)])
httpStatus.value = http
wsStatus.value = ws
if (!http && !ws) {
connectionError.value = t('connectionPanel.errorUnreachable')
} else if (!http) {
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
}
}
function connectAndGo() {
const base = normalizeUrl(backendUrl.value)
localStorage.setItem(STORAGE_KEY, base)
// Full page reload so ComfyApi constructor picks up the new backend URL
window.location.href = '/'
}
const version = __COMFYUI_FRONTEND_VERSION__
const commit = __COMFYUI_FRONTEND_COMMIT__
const branch = __CI_BRANCH__
const prNumber = __CI_PR_NUMBER__
const runId = __CI_RUN_ID__
const jobId = __CI_JOB_ID__
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,8 +13,10 @@
ref="topMenuRef"
class="app-drag h-(--comfy-topbar-height) w-full"
/>
<div class="flex w-full grow items-center justify-center overflow-auto">
<slot />
<div class="grid w-full grow place-items-center overflow-auto">
<div class="py-8">
<slot />
</div>
</div>
</div>
</template>

View File

@@ -626,7 +626,11 @@ 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_RUN_ID__: JSON.stringify(process.env.CI_RUN_ID || ''),
__CI_JOB_ID__: JSON.stringify(process.env.CI_JOB_ID || '')
},
resolve: {