mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-13 19:20:37 +00:00
Compare commits
22 Commits
test/minim
...
sno-fronte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c011360fa | ||
|
|
30ce606b0a | ||
|
|
4764a0850c | ||
|
|
b941018b44 | ||
|
|
081bf02e0f | ||
|
|
70707af9d9 | ||
|
|
7bda43a269 | ||
|
|
fddd02d778 | ||
|
|
8fd6531c57 | ||
|
|
5f47cc7997 | ||
|
|
9e2934d1ae | ||
|
|
caabc29207 | ||
|
|
00b47b52ef | ||
|
|
6c371bedfa | ||
|
|
e2156dbe2a | ||
|
|
f7f80616e0 | ||
|
|
970194e2be | ||
|
|
0415a105b9 | ||
|
|
cdcdb9aac2 | ||
|
|
24b9f171a4 | ||
|
|
b4f8605ab2 | ||
|
|
0d5ced4686 |
138
.github/workflows/ci-deploy-preview.yaml
vendored
Normal file
138
.github/workflows/ci-deploy-preview.yaml
vendored
Normal 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
63
TODO.md
Normal 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
|
||||
@@ -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"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
4
global.d.ts
vendored
@@ -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
|
||||
|
||||
212
scripts/cicd/pr-preview-deploy-and-comment.sh
Executable file
212
scripts/cicd/pr-preview-deploy-and-comment.sh
Executable 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
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
112
src/platform/connectionPanel/backendReachable.test.ts
Normal file
112
src/platform/connectionPanel/backendReachable.test.ts
Normal 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)
|
||||
)
|
||||
})
|
||||
})
|
||||
29
src/platform/connectionPanel/backendReachable.ts
Normal file
29
src/platform/connectionPanel/backendReachable.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
],
|
||||
|
||||
202
src/views/ConnectionPanelView.test.ts
Normal file
202
src/views/ConnectionPanelView.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
378
src/views/ConnectionPanelView.vue
Normal file
378
src/views/ConnectionPanelView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user