mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 06:01:58 +00:00
Compare commits
51 Commits
glary/cont
...
sno-fronte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba055befe | ||
|
|
4c8e5ad797 | ||
|
|
d3bd6f9f12 | ||
|
|
a785f72aa0 | ||
|
|
6130880992 | ||
|
|
fb46996002 | ||
|
|
bf11a90cd8 | ||
|
|
0c4861162d | ||
|
|
17f5bde180 | ||
|
|
d840020427 | ||
|
|
1a993fc1dc | ||
|
|
321c32463e | ||
|
|
0a441ab896 | ||
|
|
cf01213235 | ||
|
|
61bcf0a8bc | ||
|
|
0b90645d87 | ||
|
|
8f60294f63 | ||
|
|
da8be4dc6c | ||
|
|
3fa9c4522a | ||
|
|
2b675d6b5c | ||
|
|
66072fc4a6 | ||
|
|
5f612e19b2 | ||
|
|
eb1fe9d88a | ||
|
|
51e77c65ad | ||
|
|
324d20477e | ||
|
|
6fb9915b45 | ||
|
|
fed451edac | ||
|
|
3ee55dfa1e | ||
|
|
93073cc242 | ||
|
|
5ef07f09fa | ||
|
|
2d5d77f7db | ||
|
|
ef4d8622fa | ||
|
|
dc2d8375fd | ||
|
|
bd0a10d7f0 | ||
|
|
5b75bb5bbf | ||
|
|
8bdc850830 | ||
|
|
95d0f22906 | ||
|
|
a53ea4dae2 | ||
|
|
7ba5fcdf1a | ||
|
|
c176723bbc | ||
|
|
559001341a | ||
|
|
b9c677f54c | ||
|
|
6304a60656 | ||
|
|
0b0a1076f4 | ||
|
|
d1968f2033 | ||
|
|
f129dc2aeb | ||
|
|
1a4b766fb7 | ||
|
|
3e8022c0d1 | ||
|
|
cb696d8426 | ||
|
|
3720ba3829 | ||
|
|
cdc834705a |
147
.github/workflows/ci-deploy-preview.yaml
vendored
Normal file
147
.github/workflows/ci-deploy-preview.yaml
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
# Description: Builds ComfyUI frontend and deploys previews to Cloudflare Pages
|
||||
name: 'CI: Deploy Preview'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Post starting comment for non-forked PRs
|
||||
comment-on-pr-start:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
|
||||
./scripts/cicd/pr-preview-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"starting"
|
||||
|
||||
# Build frontend for all PRs and pushes
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
conclusion: ${{ steps.job-status.outputs.conclusion }}
|
||||
workflow-url: ${{ steps.workflow-url.outputs.url }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
FRONTEND_COMMIT_HASH: ${{ github.sha }}
|
||||
CI_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
CI_PR_NUMBER: ${{ github.event.pull_request.number || '' }}
|
||||
CI_PR_AUTHOR: ${{ github.event.pull_request.user.login || '' }}
|
||||
CI_RUN_ID: ${{ github.run_id }}
|
||||
CI_JOB_ID: ${{ github.job }}
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: pnpm build
|
||||
|
||||
- name: Set job status
|
||||
id: job-status
|
||||
if: always()
|
||||
run: |
|
||||
echo "conclusion=${{ job.status }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get workflow URL
|
||||
id: workflow-url
|
||||
if: always()
|
||||
run: |
|
||||
echo "url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload build artifact
|
||||
if: success() && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [comment-on-pr-start, build]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download build artifact
|
||||
if: needs.build.outputs.conclusion == 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-preview-deploy-and-comment.sh
|
||||
|
||||
- name: Deploy preview and comment on PR
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_CONCLUSION: ${{ needs.build.outputs.conclusion }}
|
||||
WORKFLOW_URL: ${{ needs.build.outputs.workflow-url }}
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
run: |
|
||||
./scripts/cicd/pr-preview-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"completed"
|
||||
|
||||
# Deploy to production URL on main branch push
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
FRONTEND_COMMIT_HASH: ${{ github.sha }}
|
||||
CI_BRANCH: ${{ github.ref_name }}
|
||||
CI_RUN_ID: ${{ github.run_id }}
|
||||
CI_JOB_ID: ${{ github.job }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Deploy to Cloudflare Pages (production)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
pnpm dlx wrangler@^4.0.0 pages deploy dist \
|
||||
--project-name=comfy-ui \
|
||||
--branch=main
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -99,4 +99,5 @@ vitest.config.*.timestamp*
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
.amp
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
],
|
||||
|
||||
78
docs/backend-cloud-api-base-feature-flag.md
Normal file
78
docs/backend-cloud-api-base-feature-flag.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 案 B: バックエンドの `/features` に `comfy_api_base` を追加する
|
||||
|
||||
## 背景
|
||||
|
||||
ComfyUI バックエンドは `--comfy-api-base` CLI フラグで Comfy Cloud の API ベース URL(prod / staging / カスタム)を選択する。
|
||||
フロントエンドは `__USE_PROD_CONFIG__` ビルド時定数で同じ値を選ぶ。
|
||||
両者が食い違うと、フロントエンドが発行した Firebase トークン(または API キー)が
|
||||
バックエンド経由で別の環境に投げられ、認証や課金が落ちる。
|
||||
|
||||
現状の検出方法(案 A、`src/views/ConnectionPanelView.vue`)は
|
||||
`/api/system_stats` の `system.argv`(CLI 全引数)から `--comfy-api-base` を grep するもの。
|
||||
動くが脆い:
|
||||
|
||||
- 引数の書式(`--flag VALUE` vs `--flag=VALUE`)に依存する
|
||||
- バックエンド側の CLI シグネチャが変わると壊れる
|
||||
- 「公開 API ではない情報」を検出ロジックに使っている
|
||||
|
||||
## 提案
|
||||
|
||||
ComfyUI 本体の `/features` エンドポイントに `comfy_api_base` を追加する。
|
||||
`/features` はすでに「構造化された機能/設定の公開 API」という位置付けがあり、ここに含めるのが自然。
|
||||
|
||||
### バックエンドの実装スケッチ
|
||||
|
||||
```python
|
||||
# tmp/ComfyUI/comfy_api/feature_flags.py:65 付近
|
||||
def get_server_features() -> dict[str, Any]:
|
||||
from comfy.cli_args import args
|
||||
return {
|
||||
...,
|
||||
"comfy_api_base": args.comfy_api_base,
|
||||
}
|
||||
```
|
||||
|
||||
### フロントエンドの変更
|
||||
|
||||
```ts
|
||||
// 例: src/platform/connectionPanel/ あたりに移設
|
||||
const features = await fetch(`${base}/api/features`).then((r) => r.json())
|
||||
const backendCloudBase =
|
||||
features.comfy_api_base ?? parseBackendCloudBase(stats.system?.argv)
|
||||
```
|
||||
|
||||
`features.comfy_api_base` を優先し、未定義の場合のみ `argv` フォールバックを使う。
|
||||
|
||||
## メリット
|
||||
|
||||
- 構造化された公開 API になり、CLI 変更の影響を受けない
|
||||
- 拡張機能 / カスタムノードからも安定して参照できる
|
||||
- 既存の `/features` パターン(ファースト クラスのバックエンド能力公開)に合致
|
||||
- フロントエンドの検出コードが自明になる
|
||||
|
||||
## デメリット
|
||||
|
||||
- `Comfy-Org/ComfyUI` 本体への PR とリリースが必要
|
||||
- リリース前は案 A をフォールバックとして残す必要がある
|
||||
- `comfy_api_base` を「公開してよい情報」と扱う合意が必要
|
||||
(カスタム URL を使うユーザーには内部 URL が露出することになる)
|
||||
|
||||
## ロードマップ
|
||||
|
||||
1. **案 A をフロントエンドに実装(このコミット)**
|
||||
- `ConnectionPanelView.vue` で `/system_stats` の `argv` を解析
|
||||
- 不一致を検出した場合は黄色の警告を表示
|
||||
2. `Comfy-Org/ComfyUI` に `/features` 拡張 PR を提出
|
||||
- `comfy_api/feature_flags.py:65` に `comfy_api_base` を追加
|
||||
3. 本体リリース後、フロントエンドを `features.comfy_api_base` 優先に切替
|
||||
- `argv` フォールバックは互換性のために残す
|
||||
4. 数バージョン後、`argv` フォールバックを削除
|
||||
|
||||
## 関連ファイル
|
||||
|
||||
- ComfyUI 本体: `comfy/cli_args.py:229` — `--comfy-api-base` 引数定義(デフォルト `https://api.comfy.org`)
|
||||
- ComfyUI 本体: `comfy_api/feature_flags.py:65` — `get_server_features()` の現状
|
||||
- ComfyUI 本体: `server.py:646-685` — `/system_stats` ハンドラ(`argv` を返している)
|
||||
- フロントエンド: `src/config/comfyApi.ts:21-31` — `getComfyApiBaseUrl()`(フロント側のビルド時定数)
|
||||
- フロントエンド: `src/views/ConnectionPanelView.vue` — 案 A 実装場所
|
||||
- フロントエンド: `src/platform/remoteConfig/refreshRemoteConfig.ts` — `/features` 既存利用
|
||||
@@ -27,7 +27,12 @@ const commonGlobals = {
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly',
|
||||
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
|
||||
__DISTRIBUTION__: 'readonly',
|
||||
__IS_NIGHTLY__: 'readonly'
|
||||
__IS_NIGHTLY__: 'readonly',
|
||||
__CI_BRANCH__: 'readonly',
|
||||
__CI_PR_NUMBER__: 'readonly',
|
||||
__CI_PR_AUTHOR__: 'readonly',
|
||||
__CI_RUN_ID__: 'readonly',
|
||||
__CI_JOB_ID__: 'readonly'
|
||||
} as const
|
||||
|
||||
const settings = {
|
||||
|
||||
5
global.d.ts
vendored
5
global.d.ts
vendored
@@ -2,6 +2,11 @@ declare const __COMFYUI_FRONTEND_VERSION__: string
|
||||
declare const __COMFYUI_FRONTEND_COMMIT__: string
|
||||
declare const __SENTRY_ENABLED__: boolean
|
||||
declare const __SENTRY_DSN__: string
|
||||
declare const __CI_BRANCH__: string
|
||||
declare const __CI_PR_NUMBER__: string
|
||||
declare const __CI_PR_AUTHOR__: string
|
||||
declare const __CI_RUN_ID__: string
|
||||
declare const __CI_JOB_ID__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
|
||||
209
scripts/cicd/pr-preview-deploy-and-comment.sh
Executable file
209
scripts/cicd/pr-preview-deploy-and-comment.sh
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Deploy frontend preview to Cloudflare Pages and comment on PR
|
||||
# Usage: ./pr-preview-deploy-and-comment.sh <pr_number> <status>
|
||||
|
||||
# Input validation
|
||||
# Validate PR number is numeric
|
||||
case "$1" in
|
||||
''|*[!0-9]*)
|
||||
echo "Error: PR_NUMBER must be numeric" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
PR_NUMBER="$1"
|
||||
|
||||
# Validate status parameter
|
||||
STATUS="${2:-completed}"
|
||||
case "$STATUS" in
|
||||
starting|completed) ;;
|
||||
*)
|
||||
echo "Error: STATUS must be 'starting' or 'completed'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
# Required environment variables
|
||||
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required}"
|
||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||
|
||||
# Cloudflare variables only required for deployment
|
||||
if [ "$STATUS" = "completed" ]; then
|
||||
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required for deployment}"
|
||||
: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required for deployment}"
|
||||
fi
|
||||
|
||||
# Configuration
|
||||
COMMENT_MARKER="<!-- COMFYUI_PREVIEW_DEPLOY -->"
|
||||
|
||||
# Resolve wrangler invocation: prefer a locally-available binary, otherwise
|
||||
# run via pnpm dlx to honour the repo's package-manager policy.
|
||||
if command -v wrangler > /dev/null 2>&1; then
|
||||
WRANGLER="wrangler"
|
||||
else
|
||||
WRANGLER="pnpm dlx wrangler@^4.0.0"
|
||||
fi
|
||||
|
||||
# Deploy frontend preview, WARN: ensure inputs are sanitized before calling this function
|
||||
deploy_preview() {
|
||||
dir="$1"
|
||||
branch="$2"
|
||||
|
||||
[ ! -d "$dir" ] && echo "failed" && return
|
||||
|
||||
project="comfy-ui"
|
||||
|
||||
echo "Deploying frontend preview to project $project on branch $branch..." >&2
|
||||
|
||||
# Try deployment up to 3 times
|
||||
i=1
|
||||
while [ $i -le 3 ]; do
|
||||
echo "Deployment attempt $i of 3..." >&2
|
||||
# Branch is already sanitized, use it directly
|
||||
if output=$($WRANGLER pages deploy "$dir" \
|
||||
--project-name="$project" \
|
||||
--branch="$branch" 2>&1); then
|
||||
|
||||
# Prefer the branch alias URL over the deployment hash URL so the
|
||||
# link in the PR comment stays stable across redeploys.
|
||||
branch_url="https://${branch}.${project}.pages.dev"
|
||||
if echo "$output" | grep -qF "$branch_url"; then
|
||||
result="$branch_url"
|
||||
else
|
||||
# Fall back to first pages.dev URL in wrangler output
|
||||
url=$(echo "$output" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
|
||||
result="${url:-$branch_url}"
|
||||
fi
|
||||
echo "Success! URL: $result" >&2
|
||||
echo "$result" # Only this goes to stdout for capture
|
||||
return
|
||||
else
|
||||
echo "Deployment failed on attempt $i: $output" >&2
|
||||
fi
|
||||
[ $i -lt 3 ] && sleep 10
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
echo "failed"
|
||||
}
|
||||
|
||||
# Post or update GitHub comment
|
||||
post_comment() {
|
||||
body="$1"
|
||||
temp_file=$(mktemp)
|
||||
echo "$body" > "$temp_file"
|
||||
|
||||
if command -v gh > /dev/null 2>&1; then
|
||||
# Find existing comment ID
|
||||
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
||||
--jq ".[] | select(.body | contains(\"$COMMENT_MARKER\")) | .id" | head -1)
|
||||
|
||||
if [ -n "$existing" ]; then
|
||||
# Update specific comment by ID
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" \
|
||||
--field body="$(cat "$temp_file")"
|
||||
else
|
||||
gh pr comment "$PR_NUMBER" --body-file "$temp_file"
|
||||
fi
|
||||
else
|
||||
echo "GitHub CLI not available, outputting comment:"
|
||||
cat "$temp_file"
|
||||
fi
|
||||
|
||||
rm -f "$temp_file"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
if [ "$STATUS" = "starting" ]; then
|
||||
# Post starting comment
|
||||
comment="$COMMENT_MARKER
|
||||
## 🌐 Frontend Preview: <img alt='loading' src='https://github.com/user-attachments/assets/755c86ee-e445-4ea8-bc2c-cca85df48686' width='14px' height='14px'/> Building..."
|
||||
post_comment "$comment"
|
||||
|
||||
elif [ "$STATUS" = "completed" ]; then
|
||||
# Deploy and post completion comment
|
||||
# Convert branch name to Cloudflare-compatible format (lowercase, only alphanumeric and dashes)
|
||||
# Falls back to pr-$PR_NUMBER if BRANCH_NAME is unset
|
||||
if [ -n "$BRANCH_NAME" ]; then
|
||||
cloudflare_branch=$(echo "$BRANCH_NAME" | tr '[:upper:]' '[:lower:]' | \
|
||||
sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
else
|
||||
cloudflare_branch="pr-$PR_NUMBER"
|
||||
fi
|
||||
|
||||
echo "Looking for frontend build in: $(pwd)/dist"
|
||||
|
||||
# Deploy preview if build exists
|
||||
deployment_url="Not deployed"
|
||||
if [ -d "dist" ]; then
|
||||
echo "Found frontend build, deploying..."
|
||||
url=$(deploy_preview "dist" "$cloudflare_branch")
|
||||
if [ "$url" != "failed" ] && [ -n "$url" ]; then
|
||||
deployment_url="[🌐 Open Preview]($url)"
|
||||
else
|
||||
deployment_url="Deployment failed"
|
||||
fi
|
||||
else
|
||||
echo "Frontend build not found at dist"
|
||||
fi
|
||||
|
||||
# Get workflow conclusion from environment or default to success
|
||||
WORKFLOW_CONCLUSION="${WORKFLOW_CONCLUSION:-success}"
|
||||
WORKFLOW_URL="${WORKFLOW_URL:-}"
|
||||
|
||||
# Generate compact header based on conclusion
|
||||
if [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
status_icon="✅"
|
||||
status_text="Built"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "skipped" ]; then
|
||||
status_icon="⏭️"
|
||||
status_text="Skipped"
|
||||
elif [ "$WORKFLOW_CONCLUSION" = "cancelled" ]; then
|
||||
status_icon="🚫"
|
||||
status_text="Cancelled"
|
||||
else
|
||||
status_icon="❌"
|
||||
status_text="Failed"
|
||||
fi
|
||||
|
||||
# Build compact header with optional preview link
|
||||
header="## 🌐 Frontend Preview: $status_icon $status_text"
|
||||
if [ "$deployment_url" != "Not deployed" ] && [ "$deployment_url" != "Deployment failed" ] && [ "$WORKFLOW_CONCLUSION" = "success" ]; then
|
||||
header="$header — $deployment_url"
|
||||
fi
|
||||
|
||||
# Build details section
|
||||
details="<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
⏰ Completed at: $(date -u '+%m/%d/%Y, %I:%M:%S %p') UTC
|
||||
|
||||
**Links**
|
||||
- [📊 View Workflow Run]($WORKFLOW_URL)"
|
||||
|
||||
if [ "$deployment_url" != "Not deployed" ]; then
|
||||
if [ "$deployment_url" = "Deployment failed" ]; then
|
||||
details="$details
|
||||
- ❌ Preview deployment failed"
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
details="$details
|
||||
- ⚠️ Build failed — $deployment_url"
|
||||
fi
|
||||
elif [ "$WORKFLOW_CONCLUSION" != "success" ]; then
|
||||
details="$details
|
||||
- ⏭️ Preview deployment skipped (build did not succeed)"
|
||||
fi
|
||||
|
||||
details="$details
|
||||
|
||||
</details>"
|
||||
|
||||
comment="$COMMENT_MARKER
|
||||
$header
|
||||
|
||||
$details"
|
||||
|
||||
post_comment "$comment"
|
||||
fi
|
||||
33
src/components/connection/CopyCodeBlock.vue
Normal file
33
src/components/connection/CopyCodeBlock.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<code
|
||||
class="block rounded-md bg-neutral-800 p-3 pr-10 text-xs whitespace-pre-wrap text-neutral-200 select-all"
|
||||
>{{ text }}</code
|
||||
>
|
||||
<button
|
||||
:title="copied ? t('clipboard.successMessage') : t('g.copyToClipboard')"
|
||||
:aria-label="
|
||||
copied ? t('clipboard.successMessage') : t('g.copyToClipboard')
|
||||
"
|
||||
class="absolute top-2 right-2 rounded-sm p-1 text-neutral-500 transition-colors hover:text-neutral-100"
|
||||
@click="copy(text)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
copied ? 'icon-[lucide--check] text-green-400' : 'icon-[lucide--copy]'
|
||||
"
|
||||
class="block size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { text } = defineProps<{ text: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
</script>
|
||||
@@ -42,6 +42,19 @@
|
||||
<div v-if="badge.tooltip" class="text-xs">
|
||||
{{ badge.tooltip }}
|
||||
</div>
|
||||
<template v-if="badge.popoverLinks?.length">
|
||||
<hr class="border-border-default" />
|
||||
<a
|
||||
v-for="link in badge.popoverLinks"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
:target="link.url.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -96,6 +109,19 @@
|
||||
<div v-if="badge.tooltip" class="text-xs">
|
||||
{{ badge.tooltip }}
|
||||
</div>
|
||||
<template v-if="badge.popoverLinks?.length">
|
||||
<hr class="border-border-default" />
|
||||
<a
|
||||
v-for="link in badge.popoverLinks"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
:target="link.url.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -103,10 +129,15 @@
|
||||
<!-- Full mode: Icon + Label + Text -->
|
||||
<div
|
||||
v-else
|
||||
v-tooltip="badge.tooltip"
|
||||
class="flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
|
||||
:class="[{ 'flex-row-reverse': reverseOrder }, noPadding ? '' : 'px-3']"
|
||||
v-tooltip="badge.popoverLinks?.length ? undefined : badge.tooltip"
|
||||
class="relative flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
|
||||
:class="[
|
||||
{ 'flex-row-reverse': reverseOrder },
|
||||
noPadding ? '' : 'px-3',
|
||||
badge.popoverLinks?.length ? clickableClasses : ''
|
||||
]"
|
||||
:style="menuBackgroundStyle"
|
||||
@click="badge.popoverLinks?.length ? togglePopover($event) : undefined"
|
||||
>
|
||||
<i
|
||||
v-if="iconClass"
|
||||
@@ -123,6 +154,30 @@
|
||||
<div class="font-inter text-sm" :class="textClasses">
|
||||
{{ badge.text }}
|
||||
</div>
|
||||
<Popover
|
||||
v-if="badge.popoverLinks?.length"
|
||||
ref="popover"
|
||||
append-to="body"
|
||||
:auto-z-index="true"
|
||||
:base-z-index="1000"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="popoverPt"
|
||||
>
|
||||
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
|
||||
<a
|
||||
v-for="link in badge.popoverLinks"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
:target="link.url.startsWith('http') ? '_blank' : undefined"
|
||||
:rel="link.url.startsWith('http') ? 'noopener' : undefined"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -41,3 +41,15 @@ export function getComfyPlatformBaseUrl(): string {
|
||||
BUILD_TIME_PLATFORM_BASE_URL
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Comfy Cloud API base URL (as reported by the backend) to its paired
|
||||
* platform URL where users manage their account / API keys. Returns null for
|
||||
* unknown bases so callers can hide the link rather than guess.
|
||||
*/
|
||||
export function getPlatformBaseUrlForApiBase(apiBase: string): string | null {
|
||||
const normalized = apiBase.replace(/\/+$/, '')
|
||||
if (normalized === PROD_API_BASE_URL) return PROD_PLATFORM_BASE_URL
|
||||
if (normalized === STAGING_API_BASE_URL) return STAGING_PLATFORM_BASE_URL
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -52,3 +52,8 @@ if (isCloud || isNightly) {
|
||||
if (isNightly && !isCloud) {
|
||||
await import('./nightlyBadges')
|
||||
}
|
||||
|
||||
// PR preview build badge
|
||||
if (__CI_PR_NUMBER__) {
|
||||
await import('./prPreviewBadges')
|
||||
}
|
||||
|
||||
82
src/extensions/core/prPreviewBadges.ts
Normal file
82
src/extensions/core/prPreviewBadges.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { AboutPageBadge, TopbarBadge } from '@/types/comfy'
|
||||
|
||||
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
|
||||
|
||||
const prNumber = __CI_PR_NUMBER__
|
||||
const author = __CI_PR_AUTHOR__
|
||||
const commit = __COMFYUI_FRONTEND_COMMIT__
|
||||
const commitShort = commit ? commit.slice(0, 8) : ''
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const apiNodesEnabled = settingStore.get('Comfy.NodeBadge.ShowApiPricing')
|
||||
|
||||
const backendUrl = localStorage.getItem('comfyui-preview-backend-url') ?? '—'
|
||||
|
||||
const tooltipLines = [
|
||||
author ? `@${author}` : null,
|
||||
commitShort ? commitShort : null,
|
||||
`${t('prPreview.badge.tooltipBackendLabel')}${backendUrl}`,
|
||||
apiNodesEnabled
|
||||
? t('prPreview.badge.tooltipCloudApiNote')
|
||||
: t('prPreview.badge.tooltipCloudApiDisabled')
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const popoverLinks = [
|
||||
{ label: `PR #${prNumber}`, url: `${REPO}/pull/${prNumber}` },
|
||||
...(author
|
||||
? [{ label: `@${author}`, url: `https://github.com/${author}` }]
|
||||
: []),
|
||||
...(commitShort
|
||||
? [{ label: commitShort, url: `${REPO}/commit/${commit}` }]
|
||||
: []),
|
||||
{ label: t('prPreview.badge.configureBackend'), url: '/connect' }
|
||||
]
|
||||
|
||||
const badgeText = commitShort ? `#${prNumber} · ${commitShort}` : `#${prNumber}`
|
||||
|
||||
const topbarBadges: TopbarBadge[] = [
|
||||
{
|
||||
label: t('prPreview.badge.label'),
|
||||
text: badgeText,
|
||||
variant: 'warning',
|
||||
tooltip: tooltipLines,
|
||||
popoverLinks
|
||||
}
|
||||
]
|
||||
|
||||
const aboutPageBadges: AboutPageBadge[] = [
|
||||
{
|
||||
label: `PR #${prNumber}`,
|
||||
url: `${REPO}/pull/${prNumber}`,
|
||||
icon: 'pi pi-github'
|
||||
},
|
||||
...(author
|
||||
? [
|
||||
{
|
||||
label: `@${author}`,
|
||||
url: `https://github.com/${author}`,
|
||||
icon: 'pi pi-user'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(commitShort
|
||||
? [
|
||||
{
|
||||
label: commitShort,
|
||||
url: `${REPO}/commit/${commit}`,
|
||||
icon: 'pi pi-code'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.PrPreview.Badges',
|
||||
topbarBadges,
|
||||
aboutPageBadges
|
||||
})
|
||||
@@ -3798,5 +3798,72 @@
|
||||
"training": "Training…",
|
||||
"processingVideo": "Processing video…",
|
||||
"running": "Running…"
|
||||
},
|
||||
"connectionPanel": {
|
||||
"title": "ComfyUI Frontend Preview",
|
||||
"subtitle": "Connect to a running ComfyUI backend to use this preview.",
|
||||
"previewWarningTitle": "⚠ This is a preview build of an in-flight pull request.",
|
||||
"previewWarningBody": "The UI may change rapidly as the branch is pushed and is under heavy development. Use it for testing and review only — never rely on any *.comfy-ui.pages.dev URL for production work.",
|
||||
"previewProvenance": "Built from {pr} ({commit}) by {author}.",
|
||||
"previewUnknownAuthor": "an unknown author",
|
||||
"previewTrustWarning": "Do not connect a ComfyUI instance you care about unless you trust the author of this PR — a malicious frontend can read and modify any workflow, model, or output on the connected backend.",
|
||||
"backendUrl": "Backend URL",
|
||||
"apiKey": "Comfy API Key",
|
||||
"apiKeyOptional": "(optional)",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyHint": "Only needed for cloud-API nodes (e.g. Flux, Kling). Saved to your browser.",
|
||||
"apiKeyTestOk": "API key is valid.",
|
||||
"apiKeyTestError": "Invalid or expired API key.",
|
||||
"apiKeyDisabledNotice": "The connected backend was started with --disable-api-nodes; cloud-API nodes are unavailable.",
|
||||
"test": "Test",
|
||||
"http": "HTTP",
|
||||
"ws": "WS",
|
||||
"status": "Connection Status",
|
||||
"connected": "Connected — backend is reachable.",
|
||||
"backendCloud": "Backend cloud API:",
|
||||
"cloudMismatch": "⚠ Cloud environment mismatch — this preview signs in via {frontend}, so tokens won't be accepted by the backend. Restart the backend with --comfy-api-base={frontend} (or use a frontend build that targets the backend's environment).",
|
||||
"getApiKeyLink": "→ Generate an API key for this cloud",
|
||||
"connectAndGo": "Connect & Open ComfyUI",
|
||||
"quickStart": "Quick Start with Comfy CLI",
|
||||
"quickStartDescription": "The fastest way to get ComfyUI running locally. No existing Python install required — uv handles it for you.",
|
||||
"step1InstallUv": "1. Install uv (macOS/Linux, then Windows):",
|
||||
"uvNote": "uv is a fast Python package manager that auto-installs Python itself, so you don't need Python preinstalled. After install, restart your terminal.",
|
||||
"step2InstallComfyui": "2. Install comfy-cli and ComfyUI:",
|
||||
"managerIncludedNote": "This also installs ComfyUI-Manager by default — it makes downloading missing models and custom nodes one-click, so workflows from others just work.",
|
||||
"managerTitle": "Why ComfyUI-Manager?",
|
||||
"managerDescription": "ComfyUI-Manager is bundled with comfy install. It auto-detects missing custom nodes and models referenced by any workflow you load, then installs them for you in one click — no more hunting GitHub repos or Hugging Face links by hand.",
|
||||
"managerLearnMore": "Learn more about ComfyUI-Manager →",
|
||||
"step3Launch": "3. Launch with CORS enabled:",
|
||||
"altManualSetup": "Alternative: I already have Python installed",
|
||||
"altPipDescription": "If you already have Python 3.10+ and pip available, you can install comfy-cli directly:",
|
||||
"altPipNote": "Note: older Python versions (<3.10) may fail to install some comfy-cli dependencies.",
|
||||
"altManagerDescription": "If you cloned ComfyUI manually, also install ComfyUI-Manager into custom_nodes/:",
|
||||
"guideDescription": "If you already have ComfyUI cloned, start it with CORS enabled from the repo root:",
|
||||
"corsNote": "The --enable-cors-header flag allows this preview page to communicate with your local backend.",
|
||||
"corsOriginNote": "The exact origin is pre-filled so ComfyUI can allow requests from this specific preview URL. Using * would block requests that include credentials.",
|
||||
"localAccess": "Local Network Access",
|
||||
"localAccessDescription": "Your browser may prompt for permission to access local network devices. Allow it so this page can reach your local ComfyUI instance.",
|
||||
"localAccessListenDescription": "To connect from another device on the same network (e.g. a phone or second computer), pass --listen so ComfyUI binds to all interfaces:",
|
||||
"localAccessListenNote": "Then enter your machine's LAN IP (e.g. http://192.168.1.x:8188) in the backend URL field above.",
|
||||
"source": "Source",
|
||||
"errorUnreachable": "Backend is unreachable. Ensure ComfyUI is running with CORS enabled.",
|
||||
"errorHttpFailed": "HTTP connection failed. Check the URL and CORS settings.",
|
||||
"errorWsFailed": "WebSocket connection failed. HTTP works — check firewall or proxy settings.",
|
||||
"buildPr": "PR #{prNumber}",
|
||||
"buildVersion": "v{version}",
|
||||
"tooltipVersion": "Version: {version}",
|
||||
"tooltipCommit": "Commit: {commit}",
|
||||
"tooltipBranch": "Branch: {branch}",
|
||||
"tooltipRunId": "Run ID: {runId}",
|
||||
"tooltipJobId": "Job ID: {jobId}"
|
||||
},
|
||||
"prPreview": {
|
||||
"badge": {
|
||||
"label": "PR",
|
||||
"tooltipBackendLabel": "Backend: ",
|
||||
"tooltipCloudApiNote": "Cloud API: see Settings → About",
|
||||
"tooltipCloudApiDisabled": "Cloud API: disabled",
|
||||
"configureBackend": "Configure backend →"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
})
|
||||
})
|
||||
47
src/platform/connectionPanel/backendReachable.ts
Normal file
47
src/platform/connectionPanel/backendReachable.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Probe the configured ComfyUI backend (local or remote-via-localStorage)
|
||||
* to confirm it serves the expected `/api/system_stats` shape. Used by the
|
||||
* router to decide whether to enter GraphView or redirect to /connect.
|
||||
*/
|
||||
|
||||
const BACKEND_URL_KEY = 'comfyui-preview-backend-url'
|
||||
const PROBE_TIMEOUT_MS = 3000
|
||||
|
||||
function resolveProbeBase(): string {
|
||||
const stored = localStorage.getItem(BACKEND_URL_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
// Only treat the stored value as a backend override when it's a
|
||||
// well-formed absolute URL — otherwise fall through to same-origin.
|
||||
const url = new URL(stored)
|
||||
return url.origin + url.pathname.replace(/\/+$/, '')
|
||||
} catch {
|
||||
// Ignore malformed entries; same-origin probe is safer than a
|
||||
// relative URL that misses the router's subpath base.
|
||||
}
|
||||
}
|
||||
// Mirror ComfyApi's same-origin base so subpath deployments probe the
|
||||
// backend that would actually serve the app.
|
||||
if (typeof window === 'undefined') return ''
|
||||
return window.location.pathname.split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
export async function isBackendReachable(): Promise<boolean> {
|
||||
const apiBase = resolveProbeBase()
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/system_stats`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const body = (await res.json()) as { system?: unknown }
|
||||
return !!body.system
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
24
src/platform/connectionPanel/resolveBackendCloudBase.ts
Normal file
24
src/platform/connectionPanel/resolveBackendCloudBase.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const COMFY_API_BASE_FLAG = '--comfy-api-base'
|
||||
const DEFAULT_CLOUD_API_BASE = 'https://api.comfy.org'
|
||||
|
||||
type SystemInfo = { argv?: string[]; comfy_api_base?: string }
|
||||
|
||||
function parseArgvApiBase(argv: string[] | undefined): string | undefined {
|
||||
if (!argv) return undefined
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]
|
||||
if (a === COMFY_API_BASE_FLAG && i + 1 < argv.length) return argv[i + 1]
|
||||
if (a.startsWith(`${COMFY_API_BASE_FLAG}=`))
|
||||
return a.slice(COMFY_API_BASE_FLAG.length + 1)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function resolveBackendCloudBase(
|
||||
system: SystemInfo | undefined
|
||||
): string {
|
||||
const explicit = system?.comfy_api_base
|
||||
if (explicit) return explicit.replace(/\/+$/, '')
|
||||
const fromArgv = parseArgvApiBase(system?.argv)
|
||||
return (fromArgv ?? DEFAULT_CLOUD_API_BASE).replace(/\/+$/, '')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -247,6 +247,7 @@ const zSystemStats = z.object({
|
||||
pytorch_version: z.string(),
|
||||
required_frontend_version: z.string().optional(),
|
||||
argv: z.array(z.string()),
|
||||
comfy_api_base: z.string().optional(),
|
||||
ram_total: z.number(),
|
||||
ram_free: z.number(),
|
||||
// Cloud-specific fields
|
||||
|
||||
@@ -367,27 +367,52 @@ export class ComfyApi extends EventTarget {
|
||||
*/
|
||||
apiKey?: string
|
||||
|
||||
/**
|
||||
* The origin (protocol + host) for the backend, when overridden via the
|
||||
* preview connection panel. Empty string means use same-origin.
|
||||
*/
|
||||
private remoteOrigin = ''
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.user = ''
|
||||
this.api_host = location.host
|
||||
this.api_base = isCloud
|
||||
? ''
|
||||
: location.pathname.split('/').slice(0, -1).join('/')
|
||||
|
||||
const remoteBackend = localStorage.getItem('comfyui-preview-backend-url')
|
||||
let parsedRemote: URL | null = null
|
||||
if (remoteBackend) {
|
||||
try {
|
||||
parsedRemote = new URL(remoteBackend)
|
||||
} catch {
|
||||
// Corrupt value would crash the app at startup; drop it and fall back.
|
||||
localStorage.removeItem('comfyui-preview-backend-url')
|
||||
}
|
||||
}
|
||||
if (parsedRemote) {
|
||||
this.remoteOrigin = parsedRemote.origin
|
||||
this.api_host = parsedRemote.host
|
||||
this.api_base = parsedRemote.pathname.replace(/\/+$/, '')
|
||||
} else {
|
||||
this.api_host = location.host
|
||||
this.api_base = isCloud
|
||||
? ''
|
||||
: location.pathname.split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
this.initialClientId = sessionStorage.getItem('clientId')
|
||||
}
|
||||
|
||||
internalURL(route: string): string {
|
||||
return this.api_base + '/internal' + route
|
||||
return this.remoteOrigin + this.api_base + '/internal' + route
|
||||
}
|
||||
|
||||
apiURL(route: string): string {
|
||||
if (route.startsWith('/api')) return this.api_base + route
|
||||
return this.api_base + '/api' + route
|
||||
if (route.startsWith('/api'))
|
||||
return this.remoteOrigin + this.api_base + route
|
||||
return this.remoteOrigin + this.api_base + '/api' + route
|
||||
}
|
||||
|
||||
fileURL(route: string): string {
|
||||
return this.api_base + route
|
||||
return this.remoteOrigin + this.api_base + route
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -578,8 +603,14 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const baseUrl = `${protocol}://${this.api_host}${this.api_base}/ws`
|
||||
// Derive WebSocket protocol from remote backend if set, else from page
|
||||
let wsProtocol: string
|
||||
if (this.remoteOrigin) {
|
||||
wsProtocol = this.remoteOrigin.startsWith('https:') ? 'wss' : 'ws'
|
||||
} else {
|
||||
wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
}
|
||||
const baseUrl = `${wsProtocol}://${this.api_host}${this.api_base}/ws`
|
||||
const query = params.toString()
|
||||
const wsUrl = query ? `${baseUrl}?${query}` : baseUrl
|
||||
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
],
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface TopbarBadge {
|
||||
* Optional tooltip text to show on hover
|
||||
*/
|
||||
tooltip?: string
|
||||
/**
|
||||
* Optional links rendered as clickable anchors inside the popover.
|
||||
* External URLs (starting with "http") open in a new tab; internal
|
||||
* paths (e.g. "/connect") navigate within the SPA.
|
||||
*/
|
||||
popoverLinks?: Array<{ label: string; url: string }>
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
286
src/views/ConnectionPanelView.test.ts
Normal file
286
src/views/ConnectionPanelView.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ConnectionPanelView from './ConnectionPanelView.vue'
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({ changeTheme: vi.fn() })),
|
||||
isNativeWindow: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isDesktop: false,
|
||||
isCloud: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const mockLocalStorage = vi.hoisted(() => {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
for (const key of Object.keys(store)) delete store[key]
|
||||
}),
|
||||
get length() {
|
||||
return Object.keys(store).length
|
||||
},
|
||||
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
|
||||
_store: store
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
function renderPanel() {
|
||||
return render(ConnectionPanelView, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ConnectionPanelView', () => {
|
||||
beforeEach(() => {
|
||||
mockLocalStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders the backend URL input with default value', () => {
|
||||
renderPanel()
|
||||
const input = screen.getByDisplayValue(
|
||||
'http://127.0.0.1:8188'
|
||||
) as HTMLInputElement
|
||||
expect(input).toBeTruthy()
|
||||
})
|
||||
|
||||
it('loads backend URL from localStorage', () => {
|
||||
mockLocalStorage.setItem(
|
||||
'comfyui-preview-backend-url',
|
||||
'http://192.168.1.100:8188'
|
||||
)
|
||||
renderPanel()
|
||||
const input = screen.getByDisplayValue(
|
||||
'http://192.168.1.100:8188'
|
||||
) as HTMLInputElement
|
||||
expect(input).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows test button', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByRole('button', { name: /test/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the comfy-cli install command', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByText('pip install comfy-cli')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the comfy launch command', () => {
|
||||
renderPanel()
|
||||
expect(
|
||||
screen.getByText(
|
||||
`comfy launch -- --enable-cors-header="${window.location.origin}"`
|
||||
)
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays the local network access section', () => {
|
||||
renderPanel()
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: /local/i })
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('saves URL to localStorage on test', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByDisplayValue(
|
||||
'http://127.0.0.1:8188'
|
||||
) as HTMLInputElement
|
||||
await user.clear(input)
|
||||
await user.type(input, 'http://10.0.0.1:8188')
|
||||
|
||||
const testButton = screen.getByRole('button', { name: /test/i })
|
||||
await user.click(testButton)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'comfyui-preview-backend-url',
|
||||
'http://10.0.0.1:8188'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows red HTTP indicator when fetch fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
|
||||
// Stub WebSocket to never open so wsStatus also resolves to false
|
||||
class StubWS {
|
||||
addEventListener(type: string, cb: () => void) {
|
||||
if (type === 'error') setTimeout(cb, 0)
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// i18n in tests is empty so the status text falls back to the key
|
||||
expect(screen.getByText(/connectionPanel\.error/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a URL without protocol by prepending http://', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByDisplayValue(
|
||||
'http://127.0.0.1:8188'
|
||||
) as HTMLInputElement
|
||||
await user.clear(input)
|
||||
await user.type(input, '192.168.1.50:8188')
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'comfyui-preview-backend-url',
|
||||
'http://192.168.1.50:8188'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('links to staging platform when backend uses staging cloud base', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
system: {
|
||||
argv: [
|
||||
'main.py',
|
||||
'--comfy-api-base=https://stagingapi.comfy.org'
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response)
|
||||
)
|
||||
)
|
||||
class StubWS {
|
||||
addEventListener(type: string, cb: () => void) {
|
||||
if (type === 'open') setTimeout(cb, 0)
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const link = screen.getByRole('link', {
|
||||
name: 'connectionPanel.getApiKeyLink'
|
||||
})
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://stagingplatform.comfy.org/profile/api-keys'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('parses backend cloud API base from system_stats argv', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
system: {
|
||||
argv: [
|
||||
'main.py',
|
||||
'--enable-cors-header=*',
|
||||
'--comfy-api-base',
|
||||
'https://stagingapi.comfy.org'
|
||||
]
|
||||
}
|
||||
})
|
||||
} as Response)
|
||||
)
|
||||
)
|
||||
class StubWS {
|
||||
addEventListener(type: string, cb: () => void) {
|
||||
if (type === 'open') setTimeout(cb, 0)
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText('https://stagingapi.comfy.org')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('reveals Connect & Open ComfyUI button after a successful HTTP test', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ system: { argv: [] } })
|
||||
} as Response)
|
||||
)
|
||||
)
|
||||
class StubWS {
|
||||
addEventListener(type: string, cb: () => void) {
|
||||
if (type === 'open') setTimeout(cb, 0)
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
vi.stubGlobal('WebSocket', StubWS as unknown as typeof WebSocket)
|
||||
|
||||
renderPanel()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: /test/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// i18n in tests is empty so the button label falls back to the key
|
||||
expect(screen.getByText('connectionPanel.connectAndGo')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
598
src/views/ConnectionPanelView.vue
Normal file
598
src/views/ConnectionPanelView.vue
Normal file
@@ -0,0 +1,598 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<main
|
||||
class="relative my-8 flex w-full max-w-lg flex-col gap-6 rounded-lg bg-(--comfy-menu-bg) p-8 shadow-lg"
|
||||
>
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1 class="text-xl font-semibold text-neutral-100">
|
||||
{{ t('connectionPanel.title') }}
|
||||
</h1>
|
||||
<p class="text-sm text-neutral-400">
|
||||
{{ t('connectionPanel.subtitle') }}
|
||||
</p>
|
||||
<aside
|
||||
v-if="prNumber"
|
||||
class="mt-1 flex flex-col gap-1 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{{ t('connectionPanel.previewWarningTitle') }}
|
||||
</p>
|
||||
<p class="text-amber-200/85">
|
||||
{{ t('connectionPanel.previewWarningBody') }}
|
||||
</p>
|
||||
<i18n-t
|
||||
keypath="connectionPanel.previewProvenance"
|
||||
tag="p"
|
||||
class="text-amber-200/85"
|
||||
>
|
||||
<template #pr>
|
||||
<a
|
||||
:href="prUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-amber-100"
|
||||
>#{{ prNumber }}</a
|
||||
>
|
||||
</template>
|
||||
<template #commit>
|
||||
<a
|
||||
:href="commitUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-amber-100"
|
||||
><code>{{ commitShort }}</code></a
|
||||
>
|
||||
</template>
|
||||
<template #author>
|
||||
<a
|
||||
v-if="prAuthor"
|
||||
:href="authorUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="underline hover:text-amber-100"
|
||||
>@{{ prAuthor }}</a
|
||||
>
|
||||
<span v-else>{{
|
||||
t('connectionPanel.previewUnknownAuthor')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<p class="font-medium text-amber-100">
|
||||
{{ t('connectionPanel.previewTrustWarning') }}
|
||||
</p>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<!-- Backend URL input -->
|
||||
<section class="flex flex-col gap-2">
|
||||
<label for="backend-url" class="text-sm font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.backendUrl') }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="backend-url"
|
||||
v-model="backendUrl"
|
||||
type="url"
|
||||
:placeholder="DEFAULT_BACKEND_URL"
|
||||
class="flex h-10 w-full min-w-0 appearance-none rounded-lg border-none bg-neutral-800 px-4 py-2 text-sm text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
|
||||
@keyup.enter="testConnection"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading="isTesting"
|
||||
:disabled="isTesting"
|
||||
@click="testConnection"
|
||||
>
|
||||
{{ t('connectionPanel.test') }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Connection status -->
|
||||
<section
|
||||
v-if="httpStatus !== null || wsStatus !== null"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="flex flex-col gap-2 rounded-md bg-neutral-800/50 p-3"
|
||||
>
|
||||
<h2
|
||||
class="text-xs font-medium tracking-wide text-neutral-400 uppercase"
|
||||
>
|
||||
{{ t('connectionPanel.status') }}
|
||||
</h2>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'inline-block size-2 rounded-full',
|
||||
httpStatus === true && 'bg-green-500',
|
||||
httpStatus === false && 'bg-red-500',
|
||||
httpStatus === null && 'bg-neutral-600'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ t('connectionPanel.http') }}
|
||||
{{ httpStatus === true ? '✓' : httpStatus === false ? '✗' : '—' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'inline-block size-2 rounded-full',
|
||||
wsStatus === true && 'bg-green-500',
|
||||
wsStatus === false && 'bg-red-500',
|
||||
wsStatus === null && 'bg-neutral-600'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ t('connectionPanel.ws') }}
|
||||
{{ wsStatus === true ? '✓' : wsStatus === false ? '✗' : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="connectionError" class="text-xs text-red-400">
|
||||
{{ connectionError }}
|
||||
</p>
|
||||
<p
|
||||
v-if="httpStatus === true && wsStatus === true"
|
||||
class="text-xs text-green-400"
|
||||
>
|
||||
{{ t('connectionPanel.connected') }}
|
||||
</p>
|
||||
|
||||
<!-- Backend cloud-API base + API key -->
|
||||
<div
|
||||
v-if="backendCloudBase"
|
||||
class="flex flex-col gap-3 border-t border-neutral-700 pt-2"
|
||||
>
|
||||
<p class="text-xs text-neutral-400">
|
||||
<span class="text-neutral-500"
|
||||
>{{ t('connectionPanel.backendCloud') }}
|
||||
</span>
|
||||
<code
|
||||
class="ml-1 rounded-sm bg-neutral-900 px-1 py-0.5 text-neutral-200"
|
||||
>{{ backendCloudBase }}</code
|
||||
>
|
||||
</p>
|
||||
<p v-if="cloudMismatch" class="text-xs text-amber-400">
|
||||
{{
|
||||
t('connectionPanel.cloudMismatch', {
|
||||
frontend: frontendCloudBase
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- API key input — hidden when --disable-api-nodes -->
|
||||
<div v-if="!isApiNodeDisabled" class="flex flex-col gap-1.5">
|
||||
<label for="api-key" class="text-xs font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.apiKey') }}
|
||||
<span class="ml-1 font-normal text-neutral-500">{{
|
||||
t('connectionPanel.apiKeyOptional')
|
||||
}}</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="api-key"
|
||||
v-model="apiKeyInput"
|
||||
type="password"
|
||||
:placeholder="t('connectionPanel.apiKeyPlaceholder')"
|
||||
autocomplete="current-password"
|
||||
class="flex h-8 w-full min-w-0 appearance-none rounded-md border-none bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-600 focus-visible:outline-none"
|
||||
@keyup.enter="testApiKey"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:loading="isTestingApiKey"
|
||||
:disabled="isTestingApiKey || !apiKeyInput.trim()"
|
||||
@click="testApiKey"
|
||||
>
|
||||
{{ t('connectionPanel.test') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="apiKeyStatus === 'ok'" class="text-xs text-green-400">
|
||||
{{ t('connectionPanel.apiKeyTestOk') }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="apiKeyStatus === 'error'"
|
||||
class="text-xs text-red-400"
|
||||
>
|
||||
{{ t('connectionPanel.apiKeyTestError') }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.apiKeyHint') }}
|
||||
<a
|
||||
v-if="apiKeyPageUrl"
|
||||
:href="apiKeyPageUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="ml-1 text-neutral-400 underline decoration-dotted hover:text-neutral-200"
|
||||
>
|
||||
{{ t('connectionPanel.getApiKeyLink') }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.apiKeyDisabledNotice') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Connect & Go button -->
|
||||
<Button
|
||||
v-if="httpStatus === true"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="mt-2 w-full"
|
||||
@click="connectAndGo"
|
||||
>
|
||||
{{ t('connectionPanel.connectAndGo') }}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<!-- Quick Start with Comfy CLI -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-sm font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.quickStart') }}
|
||||
</h2>
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.quickStartDescription') }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-neutral-400">
|
||||
{{ t('connectionPanel.step1InstallUv') }}
|
||||
</span>
|
||||
<CopyCodeBlock
|
||||
text="curl -LsSf https://astral.sh/uv/install.sh | sh"
|
||||
/>
|
||||
<CopyCodeBlock
|
||||
text='powershell -c "irm https://astral.sh/uv/install.ps1 | iex"'
|
||||
/>
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.uvNote') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-neutral-400">
|
||||
{{ t('connectionPanel.step2InstallComfyui') }}
|
||||
</span>
|
||||
<CopyCodeBlock
|
||||
text="uv pip install comfy-cli --system && comfy install"
|
||||
/>
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.managerIncludedNote') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-neutral-400">
|
||||
{{ t('connectionPanel.step3Launch') }}
|
||||
</span>
|
||||
<CopyCodeBlock :text="launchCmd" />
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.corsOriginNote') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.corsNote') }}
|
||||
</p>
|
||||
|
||||
<aside
|
||||
class="flex flex-col gap-1 rounded-md border border-neutral-700 bg-neutral-800/50 p-3"
|
||||
>
|
||||
<h3 class="text-xs font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.managerTitle') }}
|
||||
</h3>
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.managerDescription') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/Comfy-Org/ComfyUI-Manager"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-xs text-neutral-300 underline hover:text-neutral-100"
|
||||
>
|
||||
{{ t('connectionPanel.managerLearnMore') }}
|
||||
</a>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- Alternative: manual python / pip -->
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer text-sm font-medium text-neutral-400 hover:text-neutral-300"
|
||||
>
|
||||
{{ t('connectionPanel.altManualSetup') }}
|
||||
</summary>
|
||||
<div class="mt-2 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.altPipDescription') }}
|
||||
</p>
|
||||
<CopyCodeBlock text="pip install comfy-cli" />
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.altPipNote') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.altManagerDescription') }}
|
||||
</p>
|
||||
<CopyCodeBlock
|
||||
text="git clone https://github.com/Comfy-Org/ComfyUI-Manager.git custom_nodes/ComfyUI-Manager"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.guideDescription') }}
|
||||
</p>
|
||||
<CopyCodeBlock :text="pythonMainCmd" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Local network access -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-sm font-medium text-neutral-300">
|
||||
{{ t('connectionPanel.localAccess') }}
|
||||
</h2>
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.localAccessDescription') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs text-neutral-400">
|
||||
{{ t('connectionPanel.localAccessListenDescription') }}
|
||||
</p>
|
||||
<CopyCodeBlock :text="launchListenCmd" />
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ t('connectionPanel.localAccessListenNote') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer
|
||||
class="flex items-center justify-between border-t border-neutral-700 pt-4 text-xs text-neutral-500"
|
||||
>
|
||||
<span
|
||||
:title="buildTooltip"
|
||||
class="cursor-help underline decoration-dotted"
|
||||
>
|
||||
{{ buildLabel }}
|
||||
</span>
|
||||
<a
|
||||
:href="repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{{ t('connectionPanel.source') }}
|
||||
</a>
|
||||
</footer>
|
||||
</main>
|
||||
</BaseViewTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CopyCodeBlock from '@/components/connection/CopyCodeBlock.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
getComfyApiBaseUrl,
|
||||
getPlatformBaseUrlForApiBase
|
||||
} from '@/config/comfyApi'
|
||||
import { resolveBackendCloudBase } from '@/platform/connectionPanel/resolveBackendCloudBase'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
type SystemStats = {
|
||||
system?: { argv?: string[]; comfy_api_base?: string }
|
||||
}
|
||||
|
||||
function stripTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const DEFAULT_BACKEND_URL = 'http://127.0.0.1:8188'
|
||||
const STORAGE_KEY = 'comfyui-preview-backend-url'
|
||||
const REPO = 'https://github.com/Comfy-Org/ComfyUI_frontend'
|
||||
const corsOrigin = window.location.origin
|
||||
|
||||
const backendUrl = ref(localStorage.getItem(STORAGE_KEY) || DEFAULT_BACKEND_URL)
|
||||
const API_KEY_STORAGE_KEY = 'comfy_api_key'
|
||||
const apiKeyInput = ref(localStorage.getItem(API_KEY_STORAGE_KEY) ?? '')
|
||||
|
||||
const launchCmd = `comfy launch -- --enable-cors-header="${corsOrigin}"`
|
||||
const launchListenCmd = `comfy launch -- --listen --enable-cors-header="${corsOrigin}"`
|
||||
const pythonMainCmd = `python main.py --enable-cors-header="${corsOrigin}"`
|
||||
|
||||
const isTesting = ref(false)
|
||||
const httpStatus = ref<boolean | null>(null)
|
||||
const wsStatus = ref<boolean | null>(null)
|
||||
const connectionError = ref('')
|
||||
const backendCloudBase = ref<string | null>(null)
|
||||
const isApiNodeDisabled = ref(false)
|
||||
|
||||
const isTestingApiKey = ref(false)
|
||||
const apiKeyStatus = ref<'idle' | 'ok' | 'error'>('idle')
|
||||
const frontendCloudBase = stripTrailingSlash(getComfyApiBaseUrl())
|
||||
const cloudMismatch = computed(
|
||||
() =>
|
||||
backendCloudBase.value !== null &&
|
||||
backendCloudBase.value !== frontendCloudBase
|
||||
)
|
||||
const apiKeyPageUrl = computed(() => {
|
||||
if (!backendCloudBase.value) return null
|
||||
const platform = getPlatformBaseUrlForApiBase(backendCloudBase.value)
|
||||
return platform ? `${platform}/profile/api-keys` : null
|
||||
})
|
||||
|
||||
function normalizeUrl(raw: string): string {
|
||||
let url = raw.trim()
|
||||
if (!url) url = DEFAULT_BACKEND_URL
|
||||
if (!/^https?:\/\//i.test(url)) url = 'http://' + url
|
||||
return url.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
async function fetchSystemStats(base: string): Promise<SystemStats | null> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
try {
|
||||
const res = await fetch(`${base}/api/system_stats`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as SystemStats
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function testWs(base: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const wsUrl = new URL(base)
|
||||
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
wsUrl.pathname = '/ws'
|
||||
wsUrl.search = ''
|
||||
wsUrl.hash = ''
|
||||
const ws = new WebSocket(wsUrl.toString())
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
resolve(false)
|
||||
}, 5000)
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve(true)
|
||||
})
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
isTesting.value = true
|
||||
httpStatus.value = null
|
||||
wsStatus.value = null
|
||||
connectionError.value = ''
|
||||
backendCloudBase.value = null
|
||||
|
||||
const base = normalizeUrl(backendUrl.value)
|
||||
backendUrl.value = base
|
||||
localStorage.setItem(STORAGE_KEY, base)
|
||||
|
||||
try {
|
||||
const [stats, ws] = await Promise.all([
|
||||
fetchSystemStats(base),
|
||||
testWs(base)
|
||||
])
|
||||
httpStatus.value = stats !== null
|
||||
wsStatus.value = ws
|
||||
backendCloudBase.value = stats
|
||||
? resolveBackendCloudBase(stats.system)
|
||||
: null
|
||||
isApiNodeDisabled.value =
|
||||
stats?.system?.argv?.includes('--disable-api-nodes') ?? false
|
||||
|
||||
if (stats === null && !ws) {
|
||||
connectionError.value = t('connectionPanel.errorUnreachable')
|
||||
} else if (stats === null) {
|
||||
connectionError.value = t('connectionPanel.errorHttpFailed')
|
||||
} else if (!ws) {
|
||||
connectionError.value = t('connectionPanel.errorWsFailed')
|
||||
}
|
||||
} catch {
|
||||
httpStatus.value = false
|
||||
wsStatus.value = false
|
||||
connectionError.value = t('connectionPanel.errorUnreachable')
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testApiKey() {
|
||||
const key = apiKeyInput.value.trim()
|
||||
if (!key) return
|
||||
isTestingApiKey.value = true
|
||||
apiKeyStatus.value = 'idle'
|
||||
const base = backendCloudBase.value ?? frontendCloudBase
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 8000)
|
||||
try {
|
||||
const res = await fetch(`${base}/customers`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-API-KEY': key, 'Content-Type': 'application/json' },
|
||||
signal: controller.signal
|
||||
})
|
||||
apiKeyStatus.value = res.ok ? 'ok' : 'error'
|
||||
} catch {
|
||||
apiKeyStatus.value = 'error'
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
isTestingApiKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function connectAndGo() {
|
||||
const base = normalizeUrl(backendUrl.value)
|
||||
localStorage.setItem(STORAGE_KEY, base)
|
||||
const trimmedKey = apiKeyInput.value.trim()
|
||||
if (trimmedKey) {
|
||||
localStorage.setItem(API_KEY_STORAGE_KEY, trimmedKey)
|
||||
}
|
||||
// Full page reload so ComfyApi constructor picks up the new backend URL
|
||||
window.location.href = import.meta.env.BASE_URL || '/'
|
||||
}
|
||||
|
||||
const version = __COMFYUI_FRONTEND_VERSION__
|
||||
const commit = __COMFYUI_FRONTEND_COMMIT__
|
||||
const branch = __CI_BRANCH__
|
||||
const prNumber = __CI_PR_NUMBER__
|
||||
const prAuthor = __CI_PR_AUTHOR__
|
||||
const runId = __CI_RUN_ID__
|
||||
const jobId = __CI_JOB_ID__
|
||||
|
||||
const commitShort = commit ? commit.slice(0, 8) : ''
|
||||
const prUrl = prNumber ? `${REPO}/pull/${prNumber}` : REPO
|
||||
const commitUrl = commit ? `${REPO}/commit/${commit}` : REPO
|
||||
const authorUrl = prAuthor ? `https://github.com/${prAuthor}` : ''
|
||||
|
||||
const buildLabel = computed(() => {
|
||||
if (prNumber) return t('connectionPanel.buildPr', { prNumber })
|
||||
if (branch) return branch
|
||||
return t('connectionPanel.buildVersion', { version })
|
||||
})
|
||||
|
||||
const buildTooltip = computed(() => {
|
||||
const parts = [t('connectionPanel.tooltipVersion', { version })]
|
||||
if (commit)
|
||||
parts.push(
|
||||
t('connectionPanel.tooltipCommit', { commit: commit.slice(0, 8) })
|
||||
)
|
||||
if (branch) parts.push(t('connectionPanel.tooltipBranch', { branch }))
|
||||
if (runId) parts.push(t('connectionPanel.tooltipRunId', { runId }))
|
||||
if (jobId) parts.push(t('connectionPanel.tooltipJobId', { jobId }))
|
||||
return parts.join('\n')
|
||||
})
|
||||
|
||||
const repoUrl = computed(() => {
|
||||
if (prNumber) return `${REPO}/pull/${prNumber}`
|
||||
if (branch) return `${REPO}/tree/${branch}`
|
||||
return REPO
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.getElementById('splash-loader')?.remove()
|
||||
})
|
||||
</script>
|
||||
@@ -13,7 +13,7 @@
|
||||
ref="topMenuRef"
|
||||
class="app-drag h-(--comfy-topbar-height) w-full"
|
||||
/>
|
||||
<div class="flex w-full grow items-center justify-center overflow-auto">
|
||||
<div class="grid w-full grow place-items-center overflow-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
7
src/vite-env.d.ts
vendored
7
src/vite-env.d.ts
vendored
@@ -17,6 +17,13 @@ declare global {
|
||||
__COMFYUI_FRONTEND_VERSION__: string
|
||||
}
|
||||
|
||||
const __COMFYUI_FRONTEND_COMMIT__: string
|
||||
const __CI_BRANCH__: string
|
||||
const __CI_PR_NUMBER__: string
|
||||
const __CI_PR_AUTHOR__: string
|
||||
const __CI_RUN_ID__: string
|
||||
const __CI_JOB_ID__: string
|
||||
|
||||
interface ImportMetaEnv {
|
||||
VITE_APP_VERSION?: string
|
||||
}
|
||||
|
||||
@@ -626,7 +626,12 @@ export default defineConfig({
|
||||
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
|
||||
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
|
||||
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
|
||||
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY)
|
||||
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY),
|
||||
__CI_BRANCH__: JSON.stringify(process.env.CI_BRANCH || ''),
|
||||
__CI_PR_NUMBER__: JSON.stringify(process.env.CI_PR_NUMBER || ''),
|
||||
__CI_PR_AUTHOR__: JSON.stringify(process.env.CI_PR_AUTHOR || ''),
|
||||
__CI_RUN_ID__: JSON.stringify(process.env.CI_RUN_ID || ''),
|
||||
__CI_JOB_ID__: JSON.stringify(process.env.CI_JOB_ID || '')
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user