Compare commits

..

6 Commits

Author SHA1 Message Date
sno
a5bb4c2524 Merge branch 'main' into copilot/standardize-triggering-and-docs 2025-10-19 11:53:58 +09:00
snomiao
eae81cf2eb Merge branch 'copilot/standardize-triggering-and-docs' of github.com:Comfy-Org/ComfyUI_frontend into copilot/standardize-triggering-and-docs 2025-10-17 02:46:25 +00:00
copilot-swe-agent[bot]
ae70e458ba refactor: Address code review feedback
- Extract duplicate label checking into addUniqueLabel helper
- Improve comments explaining trigger filtering logic
- Clarify which triggers are shown in Quick Reference vs detailed sections

Co-authored-by: snomiao <7323030+snomiao@users.noreply.github.com>
2025-10-17 01:17:32 +00:00
copilot-swe-agent[bot]
254a03aba0 feat: Improve workflow documentation generation
- Add detection for needs-backport and Release labels
- Deduplicate label triggers in Quick Reference section
- Generate Quick Reference dynamically from workflows
- Show all label-triggered workflows with descriptions

Co-authored-by: snomiao <7323030+snomiao@users.noreply.github.com>
2025-10-17 01:13:05 +00:00
copilot-swe-agent[bot]
dd0ae9e2ba feat: Add automated workflow documentation generation and standardization 2025-10-17 01:08:06 +00:00
copilot-swe-agent[bot]
2527b2fe33 Initial plan 2025-10-17 00:56:07 +00:00
219 changed files with 5378 additions and 5633 deletions

View File

@@ -5,10 +5,6 @@ PLAYWRIGHT_TEST_URL=http://localhost:5173
# Proxy target of the local development server
# Note: localhost:8188 does not work.
# Cloud auto-detection: Setting this to any *.comfy.org URL automatically enables
# cloud mode (DISTRIBUTION=cloud) without needing to set DISTRIBUTION separately.
# Examples: https://testcloud.comfy.org/, https://stagingcloud.comfy.org/,
# https://pr-123.testenvs.comfy.org/, https://cloud.comfy.org/
DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
# Allow dev server access from remote IP addresses.

View File

@@ -1,5 +1,10 @@
# GitHub Workflows
This directory contains GitHub Actions workflow files that automate various aspects of the ComfyUI frontend development and release process.
> **Note:** This documentation is auto-generated from workflow files. Do not edit manually.
> Run `pnpm workflow:docs` to regenerate.
## Naming Convention
Workflow files follow a consistent naming pattern: `<prefix>-<descriptive-name>.yaml`
@@ -8,14 +13,286 @@ Workflow files follow a consistent naming pattern: `<prefix>-<descriptive-name>.
| Prefix | Purpose | Example |
| ---------- | ----------------------------------- | ------------------------------------ |
| `ci-` | Testing, linting, validation | `ci-tests-e2e.yaml` |
| `release-` | Version management, publishing | `release-version-bump.yaml` |
| `pr-` | PR automation (triggered by labels) | `pr-claude-review.yaml` |
| `api-` | External Api type generation | `api-update-registry-api-types.yaml` |
| `i18n-` | Internationalization updates | `i18n-update-core.yaml` |
| `ci-` | Testing, linting, validation | `ci-json-validation.yaml` |
| `pr-` | PR automation (triggered by labels) | `pr-backport.yaml` |
| `release-` | Version management, publishing | `release-branch-create.yaml` |
| `api-` | External API type generation | `api-update-electron-api-types.yaml` |
| `i18n-` | Internationalization updates | `i18n-update-core.yaml` |
| `publish-` | Publishing and deployment | `publish-desktop-ui-on-merge.yaml` |
| `version-` | Version management | `version-bump-desktop-ui.yaml` |
## Quick Reference
For label-triggered workflows, add the corresponding label to a PR to trigger the workflow:
- `New Browser Test Expectations` - Updates Playwright test snapshots when triggered by label or comment
- `Release` - Triggers 3 workflows
- `claude-review` - AI-powered code review triggered by adding the 'claude-review' label to a PR
- `needs-backport` - Automatically backports merged PRs to release branches when 'needs-backport' label is applied
For manual workflows, use the "Run workflow" button in the Actions tab.
## Workflow Details
### CI
#### [`ci-json-validation.yaml`](./ci-json-validation.yaml)
**Name:** CI: JSON Validation
**Description:** Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq
**Triggers:** push
#### [`ci-lint-format.yaml`](./ci-lint-format.yaml)
**Name:** CI: Lint Format
**Description:** Linting and code formatting validation for pull requests
**Triggers:** pull_request
#### [`ci-python-validation.yaml`](./ci-python-validation.yaml)
**Name:** CI: Python Validation
**Description:** Validates Python code in tools/devtools directory
**Triggers:** pull_request, push
#### [`ci-tests-e2e-forks.yaml`](./ci-tests-e2e-forks.yaml)
**Name:** CI: Tests E2E (Deploy for Forks)
**Description:** Deploys test results from forked PRs (forks can't access deployment secrets)
#### [`ci-tests-e2e.yaml`](./ci-tests-e2e.yaml)
**Name:** CI: Tests E2E
**Description:** End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
**Triggers:** pull_request, push
#### [`ci-tests-storybook-forks.yaml`](./ci-tests-storybook-forks.yaml)
**Name:** CI: Tests Storybook (Deploy for Forks)
**Description:** Deploys Storybook previews from forked PRs (forks can't access deployment secrets)
#### [`ci-tests-storybook.yaml`](./ci-tests-storybook.yaml)
**Name:** CI: Tests Storybook
**Description:** Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages
**Triggers:** workflow_dispatch (manual), pull_request
#### [`ci-tests-unit.yaml`](./ci-tests-unit.yaml)
**Name:** CI: Tests Unit
**Description:** Unit and component testing with Vitest
**Triggers:** pull_request, push
#### [`ci-workflow-docs.yaml`](./ci-workflow-docs.yaml)
**Name:** CI: Workflow Documentation
**Description:** Validates that workflow documentation is up-to-date with workflow files
**Triggers:** pull_request
### PR
#### [`pr-backport.yaml`](./pr-backport.yaml)
**Name:** PR Backport
**Description:** Automatically backports merged PRs to release branches when 'needs-backport' label is applied
**Triggers:** workflow_dispatch (manual), pull_request_target (closed, labeled)
**Label Triggers:** `needs-backport`
#### [`pr-claude-review.yaml`](./pr-claude-review.yaml)
**Name:** PR: Claude Review
**Description:** AI-powered code review triggered by adding the 'claude-review' label to a PR
**Triggers:** pull_request (labeled)
**Label Triggers:** `claude-review`
#### [`pr-update-playwright-expectations.yaml`](./pr-update-playwright-expectations.yaml)
**Name:** PR: Update Playwright Expectations
**Description:** Updates Playwright test snapshots when triggered by label or comment
**Triggers:** pull_request (labeled), issue_comment (created)
**Label Triggers:** `New Browser Test Expectations`, `/update-playwright`
### RELEASE
#### [`release-branch-create.yaml`](./release-branch-create.yaml)
**Name:** Release Branch Create
**Description:** Creates release branch when version bump PR with 'Release' label is merged
**Triggers:** pull_request (closed)
**Label Triggers:** `Release`
#### [`release-draft-create.yaml`](./release-draft-create.yaml)
**Name:** Release Draft Create
**Description:** Creates GitHub release draft when version bump PR with 'Release' label is merged
**Triggers:** pull_request (closed)
**Label Triggers:** `Release`
#### [`release-npm-types.yaml`](./release-npm-types.yaml)
**Name:** Release NPM Types
**Description:** Manual workflow to publish TypeScript type definitions to npm
**Triggers:** workflow_dispatch (manual)
#### [`release-pypi-dev.yaml`](./release-pypi-dev.yaml)
**Name:** Release PyPI Dev
**Description:** Manual workflow to publish development version to PyPI
**Triggers:** workflow_dispatch (manual)
#### [`release-version-bump.yaml`](./release-version-bump.yaml)
**Name:** Release: Version Bump
**Description:** Manual workflow to increment package version with semantic versioning support
**Triggers:** workflow_dispatch (manual)
### API
#### [`api-update-electron-api-types.yaml`](./api-update-electron-api-types.yaml)
**Name:** Api: Update Electron API Types
**Description:** When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo
**Triggers:** workflow_dispatch (manual)
#### [`api-update-manager-api-types.yaml`](./api-update-manager-api-types.yaml)
**Name:** Api: Update Manager API Types
**Description:** When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo
**Triggers:** workflow_dispatch (manual)
#### [`api-update-registry-api-types.yaml`](./api-update-registry-api-types.yaml)
**Name:** Api: Update Registry API Types
**Description:** When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
**Triggers:** workflow_dispatch (manual)
### I18N
#### [`i18n-update-core.yaml`](./i18n-update-core.yaml)
**Name:** i18n: Update Core
**Description:** Generates and updates translations for core ComfyUI components using OpenAI
**Triggers:** workflow_dispatch (manual), pull_request (opened, synchronize, reopened)
#### [`i18n-update-custom-nodes.yaml`](./i18n-update-custom-nodes.yaml)
**Name:** i18n Update Custom Nodes
**Description:** Updates translations for custom node repositories using OpenAI
**Triggers:** workflow_dispatch (manual)
#### [`i18n-update-nodes.yaml`](./i18n-update-nodes.yaml)
**Name:** i18n Update Nodes
**Description:** Updates translations for ComfyUI node definitions
**Triggers:** workflow_dispatch (manual)
### PUBLISH
#### [`publish-desktop-ui-on-merge.yaml`](./publish-desktop-ui-on-merge.yaml)
**Name:** Publish Desktop UI on PR Merge
**Description:** Automatically publishes desktop UI package to npm when version bump PR is merged
**Triggers:** pull_request (closed)
**Label Triggers:** `Release`
#### [`publish-desktop-ui.yaml`](./publish-desktop-ui.yaml)
**Name:** Publish Desktop UI
**Description:** Manual workflow to publish desktop UI package to npm with specified version
**Triggers:** workflow_dispatch (manual)
### VERSION
#### [`version-bump-desktop-ui.yaml`](./version-bump-desktop-ui.yaml)
**Name:** Version Bump Desktop UI
**Description:** Manual workflow to increment desktop UI package version with semantic versioning support
**Triggers:** workflow_dispatch (manual)
## Documentation
Each workflow file contains comments explaining its purpose, triggers, and behavior. For specific details about what each workflow does, refer to the comments at the top of each `.yaml` file.
For more information about GitHub Actions, see:
- [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
- [Workflow syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions)
For GitHub Actions documentation, see [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows).
## Maintaining Workflows
### Adding a New Workflow
1. Create a new workflow file following the naming convention
2. Include `name` and `description` fields at the top of the workflow
3. Run `pnpm workflow:docs` to update this README
4. Commit both the workflow file and updated README
### Best Practices
1. **Always include a description**: Add a `description` field after the `name` field
2. **Use consistent prefixes**: Follow the established prefix conventions
3. **Label-triggered workflows**: For PR automation, use the `pr-` prefix
4. **Document triggers**: Make trigger conditions clear in the workflow description
5. **Keep docs in sync**: Run `pnpm workflow:docs` after any workflow changes

View File

@@ -26,6 +26,15 @@ jobs:
node-version: lts/*
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
key: electron-types-tools-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
electron-types-tools-cache-${{ runner.os }}-
- name: Update electron types
run: pnpm install --workspace-root @comfyorg/comfyui-electron-types@latest

View File

@@ -31,9 +31,26 @@ jobs:
node-version: lts/*
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
key: update-manager-tools-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
update-manager-tools-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache ComfyUI-Manager repository
uses: actions/cache@v4
with:
path: ComfyUI-Manager
key: comfyui-manager-repo-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
comfyui-manager-repo-${{ runner.os }}-
- name: Checkout ComfyUI-Manager repository
uses: actions/checkout@v5
with:

View File

@@ -30,9 +30,26 @@ jobs:
node-version: lts/*
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
key: update-registry-tools-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
update-registry-tools-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache comfy-api repository
uses: actions/cache@v4
with:
path: comfy-api
key: comfy-api-repo-${{ runner.os }}-${{ github.run_id }}
restore-keys: |
comfy-api-repo-${{ runner.os }}-
- name: Checkout comfy-api repository
uses: actions/checkout@v5
with:

View File

@@ -33,15 +33,27 @@ jobs:
node-version: 'lts/*'
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
.eslintcache
tsconfig.tsbuildinfo
.prettierCache
.knip-cache
key: lint-format-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.{ts,vue,js,mts}', '*.config.*', '.eslintrc.*', '.prettierrc.*', 'tsconfig.json') }}
restore-keys: |
lint-format-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
lint-format-cache-${{ runner.os }}-
ci-tools-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ESLint with auto-fix
run: pnpm lint:fix
- name: Run Stylelint with auto-fix
run: pnpm stylelint:fix
- name: Run Prettier with auto-format
run: pnpm format
@@ -66,7 +78,6 @@ jobs:
- name: Final validation
run: |
pnpm lint
pnpm stylelint
pnpm format:check
pnpm knip

View File

@@ -50,6 +50,19 @@ jobs:
node-version: '20'
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
storybook-static
tsconfig.tsbuildinfo
key: storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.{ts,vue,js}', '*.config.*', '.storybook/**/*') }}
restore-keys: |
storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
storybook-cache-${{ runner.os }}-
storybook-tools-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -102,6 +115,19 @@ jobs:
node-version: '20'
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
storybook-static
tsconfig.tsbuildinfo
key: storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.{ts,vue,js}', '*.config.*', '.storybook/**/*') }}
restore-keys: |
storybook-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
storybook-cache-${{ runner.os }}-
storybook-tools-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile

View File

@@ -29,6 +29,19 @@ jobs:
node-version: "lts/*"
cache: "pnpm"
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
coverage
.vitest-cache
key: vitest-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.{ts,vue,js}', 'vitest.config.*', 'tsconfig.json') }}
restore-keys: |
vitest-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
vitest-cache-${{ runner.os }}-
test-tools-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile

43
.github/workflows/ci-workflow-docs.yaml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: "CI: Workflow Documentation"
description: "Validates that workflow documentation is up-to-date with workflow files"
on:
pull_request:
paths:
- '.github/workflows/*.yaml'
- '.github/workflows/*.yml'
- 'scripts/cicd/generate-workflow-docs.ts'
jobs:
check-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate workflow documentation
run: pnpm workflow:docs
- name: Check if documentation is up-to-date
run: |
if [ -n "$(git status --porcelain .github/workflows/README.md)" ]; then
echo "::error::Workflow documentation is out of date. Please run 'pnpm workflow:docs' and commit the changes."
git diff .github/workflows/README.md
exit 1
else
echo "✓ Workflow documentation is up-to-date"
fi

View File

@@ -1,4 +1,5 @@
name: i18n Update Custom Nodes
description: "Updates translations for custom node repositories using OpenAI"
on:
workflow_dispatch:

View File

@@ -1,4 +1,5 @@
name: i18n Update Nodes
description: "Updates translations for ComfyUI node definitions"
on:
workflow_dispatch:

View File

@@ -1,4 +1,5 @@
name: PR Backport
description: "Automatically backports merged PRs to release branches when 'needs-backport' label is applied"
on:
pull_request_target:

View File

@@ -1,5 +1,6 @@
# Setting test expectation screenshots for Playwright
name: "PR: Update Playwright Expectations"
description: "Updates Playwright test snapshots when triggered by label or comment"
on:
pull_request:
@@ -12,11 +13,11 @@ concurrency:
cancel-in-progress: true
jobs:
setup:
test:
runs-on: ubuntu-latest
if: >
( github.event_name == 'pull_request' && github.event.label.name == 'New Browser Test Expectations' ) ||
( github.event.issue.pull_request &&
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
@@ -24,25 +25,12 @@ jobs:
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-playwright') )
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
pr-number: ${{ steps.pr-info.outputs.pr-number }}
branch: ${{ steps.pr-info.outputs.branch }}
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
steps:
- name: Get PR info
id: pr-info
run: |
echo "pr-number=${{ github.event.number || github.event.issue.number }}" >> $GITHUB_OUTPUT
echo "branch=$(gh pr view ${{ github.event.number || github.event.issue.number }} --repo ${{ github.repository }} --json headRefName --jq '.headRefName')" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Find Update Comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
id: "find-update-comment"
with:
issue-number: ${{ steps.pr-info.outputs.pr-number }}
issue-number: ${{ github.event.number || github.event.issue.number }}
comment-author: "github-actions[bot]"
body-includes: "Updating Playwright Expectations"
@@ -50,220 +38,49 @@ jobs:
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ steps.pr-info.outputs.pr-number }}
issue-number: ${{ github.event.number || github.event.issue.number }}
body: |
Updating Playwright Expectations
edit-mode: replace
reactions: eyes
- name: Checkout repository
- name: Get Branch SHA
id: "get-branch"
run: echo ::set-output name=branch::$(gh pr view $PR_NO --repo $REPO --json headRefName --jq '.headRefName')
env:
REPO: ${{ github.repository }}
PR_NO: ${{ github.event.number || github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Initial Checkout
uses: actions/checkout@v5
with:
ref: ${{ steps.pr-info.outputs.branch }}
- name: Setup frontend
ref: ${{ steps.get-branch.outputs.branch }}
- name: Setup Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
# Save expensive build artifacts (Python env, built frontend, node_modules)
# Source code will be checked out fresh in sharded jobs
- name: Generate cache key
id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with:
path: |
ComfyUI
dist
key: comfyui-setup-${{ steps.cache-key.outputs.key }}
# Sharded snapshot updates
update-snapshots-sharded:
needs: setup
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
# Checkout source code fresh (not cached)
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ needs.setup.outputs.branch }}
# Restore expensive build artifacts from setup job
- name: Restore cached artifacts
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
with:
fail-on-cache-miss: true
path: |
ComfyUI
dist
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
- name: Setup ComfyUI server (from cache)
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup nodejs, pnpm, reuse built frontend
uses: ./.github/actions/setup-frontend
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
# Run sharded tests with snapshot updates
- name: Update snapshots (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
- name: Run Playwright tests and update snapshots
id: playwright-tests
run: pnpm exec playwright test --update-snapshots --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
run: pnpm exec playwright test --update-snapshots
continue-on-error: true
# Identify and stage only changed snapshot files
- name: Stage changed snapshot files
id: changed-snapshots
run: |
echo "=========================================="
echo "STAGING CHANGED SNAPSHOTS (Shard ${{ matrix.shardIndex }})"
echo "=========================================="
# Get list of changed snapshot files
changed_files=$(git diff --name-only browser_tests/ 2>/dev/null | grep -E '\-snapshots/' || echo "")
if [ -z "$changed_files" ]; then
echo "No snapshot changes in this shard"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "✓ Found changed files:"
echo "$changed_files"
file_count=$(echo "$changed_files" | wc -l)
echo "Count: $file_count"
echo "has-changes=true" >> $GITHUB_OUTPUT
echo ""
# Create staging directory
mkdir -p /tmp/changed_snapshots_shard
# Copy only changed files, preserving directory structure
echo "Copying changed files to staging directory..."
while IFS= read -r file; do
# Create parent directories
mkdir -p "/tmp/changed_snapshots_shard/$(dirname "$file")"
# Copy file
cp "$file" "/tmp/changed_snapshots_shard/$file"
echo " → $file"
done <<< "$changed_files"
echo ""
echo "Staged files for upload:"
find /tmp/changed_snapshots_shard -type f
# Upload ONLY the changed files from this shard
- name: Upload changed snapshots
uses: actions/upload-artifact@v4
if: steps.changed-snapshots.outputs.has-changes == 'true'
with:
name: snapshots-shard-${{ matrix.shardIndex }}
path: /tmp/changed_snapshots_shard/
retention-days: 1
- name: Upload test report
uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-shard-${{ matrix.shardIndex }}
name: playwright-report
path: ./playwright-report/
retention-days: 30
# Merge snapshots and commit
merge-and-commit:
needs: [setup, update-snapshots-sharded]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ needs.setup.outputs.branch }}
# Download all snapshot artifacts from shards
- name: Download all snapshots
uses: actions/download-artifact@v4
with:
pattern: snapshots-shard-*
path: ./downloaded-snapshots
merge-multiple: false
- name: List downloaded files
- name: Debugging info
run: |
echo "=========================================="
echo "DOWNLOADED SNAPSHOT FILES"
echo "=========================================="
find ./downloaded-snapshots -type f
echo ""
echo "Total files: $(find ./downloaded-snapshots -type f | wc -l)"
# Merge only the changed files into browser_tests
- name: Merge changed snapshots
run: |
set -euo pipefail
echo "=========================================="
echo "MERGING CHANGED SNAPSHOTS"
echo "=========================================="
# Verify target directory exists
if [ ! -d "browser_tests" ]; then
echo "::error::Target directory 'browser_tests' does not exist"
exit 1
fi
merged_count=0
# For each shard's changed files, copy them directly
for shard_dir in ./downloaded-snapshots/snapshots-shard-*/; do
if [ ! -d "$shard_dir" ]; then
continue
fi
shard_name=$(basename "$shard_dir")
file_count=$(find "$shard_dir" -type f | wc -l)
if [ "$file_count" -eq 0 ]; then
echo " $shard_name: no files"
continue
fi
echo "Processing $shard_name ($file_count file(s))..."
# Copy files directly, preserving directory structure
# Since we only have changed files, just copy them all
cp -v -r "$shard_dir"* browser_tests/ 2>&1 | sed 's/^/ /'
merged_count=$((merged_count + 1))
echo " ✓ Merged"
echo ""
done
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Shards merged: $merged_count"
- name: Show changes
run: |
echo "=========================================="
echo "CHANGES SUMMARY"
echo "=========================================="
echo ""
echo "Changed files in browser_tests:"
git diff --name-only browser_tests/ | head -20 || echo "No changes"
echo ""
echo "Total changes:"
git diff --name-only browser_tests/ | wc -l || echo "0"
echo "PR: ${{ github.event.issue.number }}"
echo "Branch: ${{ steps.get-branch.outputs.branch }}"
git status
- name: Commit updated expectations
run: |
git config --global user.name 'github-actions'
@@ -273,20 +90,20 @@ jobs:
echo "No changes to commit"
else
git commit -m "[automated] Update test expectations"
git push origin ${{ needs.setup.outputs.branch }}
git push origin ${{ steps.get-branch.outputs.branch }}
fi
- name: Add Done Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
if: github.event_name == 'issue_comment'
with:
comment-id: ${{ needs.setup.outputs.comment-id }}
issue-number: ${{ needs.setup.outputs.pr-number }}
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ github.event.number || github.event.issue.number }}
reactions: +1
reactions-edit-mode: replace
- name: Remove New Browser Test Expectations label
if: always() && github.event_name == 'pull_request'
run: gh pr edit ${{ needs.setup.outputs.pr-number }} --remove-label "New Browser Test Expectations"
run: gh pr edit ${{ github.event.pull_request.number }} --remove-label "New Browser Test Expectations"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,4 +1,5 @@
name: Publish Desktop UI on PR Merge
description: "Automatically publishes desktop UI package to npm when version bump PR is merged"
on:
pull_request:

View File

@@ -1,4 +1,5 @@
name: Publish Desktop UI
description: "Manual workflow to publish desktop UI package to npm with specified version"
on:
workflow_dispatch:
@@ -161,6 +162,20 @@ jobs:
echo "publish_dir=$PUBLISH_DIR" >> "$GITHUB_OUTPUT"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
- name: Pack (preview only)
shell: bash
working-directory: ${{ steps.pkg.outputs.publish_dir }}
run: |
set -euo pipefail
npm pack --json | tee pack-result.json
- name: Upload package tarball artifact
uses: actions/upload-artifact@v4
with:
name: desktop-ui-npm-tarball-${{ inputs.version }}
path: ${{ steps.pkg.outputs.publish_dir }}/*.tgz
if-no-files-found: error
- name: Check if version already on npm
id: check_npm
env:

View File

@@ -1,4 +1,5 @@
name: Release Branch Create
description: "Creates release branch when version bump PR with 'Release' label is merged"
on:
pull_request:

View File

@@ -1,4 +1,5 @@
name: Release Draft Create
description: "Creates GitHub release draft when version bump PR with 'Release' label is merged"
on:
pull_request:
@@ -28,6 +29,16 @@ jobs:
node-version: 'lts/*'
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
tsconfig.tsbuildinfo
key: release-tools-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
release-tools-cache-${{ runner.os }}-
- name: Get current version
id: current_version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT

View File

@@ -1,4 +1,5 @@
name: Release NPM Types
description: "Manual workflow to publish TypeScript type definitions to npm"
on:
workflow_dispatch:

View File

@@ -1,4 +1,5 @@
name: Release PyPI Dev
description: "Manual workflow to publish development version to PyPI"
on:
workflow_dispatch:
@@ -25,6 +26,17 @@ jobs:
node-version: 'lts/*'
cache: 'pnpm'
- name: Cache tool outputs
uses: actions/cache@v4
with:
path: |
.cache
dist
tsconfig.tsbuildinfo
key: dev-release-tools-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
dev-release-tools-cache-${{ runner.os }}-
- name: Get current version
id: current_version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT

View File

@@ -59,6 +59,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
- name: Bump version
id: bump-version

View File

@@ -1,4 +1,5 @@
name: Version Bump Desktop UI
description: "Manual workflow to increment desktop UI package version with semantic versioning support"
on:
workflow_dispatch:

2
.gitignore vendored
View File

@@ -78,7 +78,7 @@ templates_repo/
vite.config.mts.timestamp-*.mjs
# Linux core dumps
/core
./core
*storybook.log
storybook-static

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Run Knip with cache via package script
pnpm knip 1>&2
pnpm knip

View File

@@ -7,5 +7,12 @@
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"]
"overrides": [
{
"files": "*.{js,cjs,mjs,ts,cts,mts,tsx,vue}",
"options": {
"plugins": ["@trivago/prettier-plugin-sort-imports"]
}
}
]
}

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>ComfyUI</title>
<title>ComfyUI Desktop</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/desktop-ui",
"version": "0.0.3",
"version": "0.0.1",
"type": "module",
"nx": {
"tags": [

View File

@@ -1,17 +1,17 @@
<template>
<div
class="mx-auto grid h-[40rem] w-full max-w-3xl grid-rows-[1fr_auto_auto_1fr] select-none"
class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
>
<h2 class="text-center font-inter text-3xl font-bold text-neutral-100">
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.gpuPicker.title') }}
</h2>
<!-- GPU Selection buttons - takes up remaining space and centers content -->
<div class="flex flex-1 items-center justify-center gap-8">
<div class="flex-1 flex gap-8 justify-center items-center">
<!-- Apple Metal / NVIDIA -->
<HardwareOption
v-if="platform === 'darwin'"
:image-path="'./assets/images/apple-mps-logo.png'"
:image-path="'/assets/images/apple-mps-logo.png'"
placeholder-text="Apple Metal"
subtitle="Apple Metal"
:value="'mps'"
@@ -21,7 +21,7 @@
/>
<HardwareOption
v-else
:image-path="'./assets/images/nvidia-logo-square.jpg'"
:image-path="'/assets/images/nvidia-logo-square.jpg'"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:value="'nvidia'"
@@ -47,17 +47,17 @@
/>
</div>
<div class="h-16 px-24 pt-12">
<div class="pt-12 px-24 h-16">
<div v-show="showRecommendedBadge" class="flex items-center gap-2">
<Tag
:value="$t('install.gpuPicker.recommended')"
class="rounded-full bg-neutral-300 px-2 py-[1px] text-sm font-bold text-neutral-900"
class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
/>
<i class="icon-[lucide--badge-check] text-lg text-neutral-300" />
<i class="icon-[lucide--badge-check] text-neutral-300 text-lg" />
</div>
</div>
<div class="px-24 text-neutral-300">
<div class="text-neutral-300 px-24">
<p v-show="descriptionText" class="leading-relaxed">
{{ descriptionText }}
</p>

View File

@@ -1,163 +1,67 @@
// Import only English locale eagerly as the default/fallback
// ESLint cannot statically resolve dynamic imports with path aliases (@frontend-locales/*),
// but these are properly configured in tsconfig.json and resolved by Vite at build time.
// eslint-disable-next-line import-x/no-unresolved
import arCommands from '@frontend-locales/ar/commands.json' with { type: 'json' }
import ar from '@frontend-locales/ar/main.json' with { type: 'json' }
import arNodes from '@frontend-locales/ar/nodeDefs.json' with { type: 'json' }
import arSettings from '@frontend-locales/ar/settings.json' with { type: 'json' }
import enCommands from '@frontend-locales/en/commands.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import en from '@frontend-locales/en/main.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
// eslint-disable-next-line import-x/no-unresolved
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import esCommands from '@frontend-locales/es/commands.json' with { type: 'json' }
import es from '@frontend-locales/es/main.json' with { type: 'json' }
import esNodes from '@frontend-locales/es/nodeDefs.json' with { type: 'json' }
import esSettings from '@frontend-locales/es/settings.json' with { type: 'json' }
import frCommands from '@frontend-locales/fr/commands.json' with { type: 'json' }
import fr from '@frontend-locales/fr/main.json' with { type: 'json' }
import frNodes from '@frontend-locales/fr/nodeDefs.json' with { type: 'json' }
import frSettings from '@frontend-locales/fr/settings.json' with { type: 'json' }
import jaCommands from '@frontend-locales/ja/commands.json' with { type: 'json' }
import ja from '@frontend-locales/ja/main.json' with { type: 'json' }
import jaNodes from '@frontend-locales/ja/nodeDefs.json' with { type: 'json' }
import jaSettings from '@frontend-locales/ja/settings.json' with { type: 'json' }
import koCommands from '@frontend-locales/ko/commands.json' with { type: 'json' }
import ko from '@frontend-locales/ko/main.json' with { type: 'json' }
import koNodes from '@frontend-locales/ko/nodeDefs.json' with { type: 'json' }
import koSettings from '@frontend-locales/ko/settings.json' with { type: 'json' }
import ruCommands from '@frontend-locales/ru/commands.json' with { type: 'json' }
import ru from '@frontend-locales/ru/main.json' with { type: 'json' }
import ruNodes from '@frontend-locales/ru/nodeDefs.json' with { type: 'json' }
import ruSettings from '@frontend-locales/ru/settings.json' with { type: 'json' }
import trCommands from '@frontend-locales/tr/commands.json' with { type: 'json' }
import tr from '@frontend-locales/tr/main.json' with { type: 'json' }
import trNodes from '@frontend-locales/tr/nodeDefs.json' with { type: 'json' }
import trSettings from '@frontend-locales/tr/settings.json' with { type: 'json' }
import zhTWCommands from '@frontend-locales/zh-TW/commands.json' with { type: 'json' }
import zhTW from '@frontend-locales/zh-TW/main.json' with { type: 'json' }
import zhTWNodes from '@frontend-locales/zh-TW/nodeDefs.json' with { type: 'json' }
import zhTWSettings from '@frontend-locales/zh-TW/settings.json' with { type: 'json' }
import zhCommands from '@frontend-locales/zh/commands.json' with { type: 'json' }
import zh from '@frontend-locales/zh/main.json' with { type: 'json' }
import zhNodes from '@frontend-locales/zh/nodeDefs.json' with { type: 'json' }
import zhSettings from '@frontend-locales/zh/settings.json' with { type: 'json' }
import { createI18n } from 'vue-i18n'
function buildLocale<
M extends Record<string, unknown>,
N extends Record<string, unknown>,
C extends Record<string, unknown>,
S extends Record<string, unknown>
>(main: M, nodes: N, commands: C, settings: S) {
function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
return {
...main,
nodeDefs: nodes,
commands: commands,
settings: settings
} as M & { nodeDefs: N; commands: C; settings: S }
}
// Locale loader map - dynamically import locales only when needed
// ESLint cannot statically resolve these dynamic imports, but they are valid at build time
/* eslint-disable import-x/no-unresolved */
const localeLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('@frontend-locales/ar/main.json'),
es: () => import('@frontend-locales/es/main.json'),
fr: () => import('@frontend-locales/fr/main.json'),
ja: () => import('@frontend-locales/ja/main.json'),
ko: () => import('@frontend-locales/ko/main.json'),
ru: () => import('@frontend-locales/ru/main.json'),
tr: () => import('@frontend-locales/tr/main.json'),
zh: () => import('@frontend-locales/zh/main.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/main.json')
}
const nodeDefsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('@frontend-locales/ar/nodeDefs.json'),
es: () => import('@frontend-locales/es/nodeDefs.json'),
fr: () => import('@frontend-locales/fr/nodeDefs.json'),
ja: () => import('@frontend-locales/ja/nodeDefs.json'),
ko: () => import('@frontend-locales/ko/nodeDefs.json'),
ru: () => import('@frontend-locales/ru/nodeDefs.json'),
tr: () => import('@frontend-locales/tr/nodeDefs.json'),
zh: () => import('@frontend-locales/zh/nodeDefs.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/nodeDefs.json')
}
const commandsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('@frontend-locales/ar/commands.json'),
es: () => import('@frontend-locales/es/commands.json'),
fr: () => import('@frontend-locales/fr/commands.json'),
ja: () => import('@frontend-locales/ja/commands.json'),
ko: () => import('@frontend-locales/ko/commands.json'),
ru: () => import('@frontend-locales/ru/commands.json'),
tr: () => import('@frontend-locales/tr/commands.json'),
zh: () => import('@frontend-locales/zh/commands.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/commands.json')
}
const settingsLoaders: Record<
string,
() => Promise<{ default: Record<string, unknown> }>
> = {
ar: () => import('@frontend-locales/ar/settings.json'),
es: () => import('@frontend-locales/es/settings.json'),
fr: () => import('@frontend-locales/fr/settings.json'),
ja: () => import('@frontend-locales/ja/settings.json'),
ko: () => import('@frontend-locales/ko/settings.json'),
ru: () => import('@frontend-locales/ru/settings.json'),
tr: () => import('@frontend-locales/tr/settings.json'),
zh: () => import('@frontend-locales/zh/settings.json'),
'zh-TW': () => import('@frontend-locales/zh-TW/settings.json')
}
// Track which locales have been loaded
const loadedLocales = new Set<string>(['en'])
// Track locales currently being loaded to prevent race conditions
const loadingLocales = new Map<string, Promise<void>>()
/**
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
*/
export async function loadLocale(locale: string): Promise<void> {
if (loadedLocales.has(locale)) {
return
}
// If already loading, return the existing promise to prevent duplicate loads
const existingLoad = loadingLocales.get(locale)
if (existingLoad) {
return existingLoad
}
const loader = localeLoaders[locale]
const nodeDefsLoader = nodeDefsLoaders[locale]
const commandsLoader = commandsLoaders[locale]
const settingsLoader = settingsLoaders[locale]
if (!loader || !nodeDefsLoader || !commandsLoader || !settingsLoader) {
console.warn(`Locale "${locale}" is not supported`)
return
}
// Create and track the loading promise
const loadPromise = (async () => {
try {
const [main, nodes, commands, settings] = await Promise.all([
loader(),
nodeDefsLoader(),
commandsLoader(),
settingsLoader()
])
const messages = buildLocale(
main.default,
nodes.default,
commands.default,
settings.default
)
i18n.global.setLocaleMessage(locale, messages as LocaleMessages)
loadedLocales.add(locale)
} catch (error) {
console.error(`Failed to load locale "${locale}":`, error)
throw error
} finally {
// Clean up the loading promise once complete
loadingLocales.delete(locale)
}
})()
loadingLocales.set(locale, loadPromise)
return loadPromise
}
// Only include English in the initial bundle
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)
en: buildLocale(en, enNodes, enCommands, enSettings),
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings),
ru: buildLocale(ru, ruNodes, ruCommands, ruSettings),
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
ko: buildLocale(ko, koNodes, koCommands, koSettings),
fr: buildLocale(fr, frNodes, frCommands, frSettings),
es: buildLocale(es, esNodes, esCommands, esSettings),
ar: buildLocale(ar, arNodes, arCommands, arSettings),
tr: buildLocale(tr, trNodes, trCommands, trSettings)
}
// Type for locale messages - inferred from the English locale structure
type LocaleMessages = typeof messages.en
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,

View File

@@ -66,6 +66,17 @@
@click="troubleshoot"
/>
</div>
<div class="text-center">
<button
v-if="!terminalVisible"
class="text-sm text-neutral-500 hover:text-neutral-300 transition-colors flex items-center gap-2 mx-auto"
@click="terminalVisible = true"
>
<i class="pi pi-search"></i>
{{ $t('serverStart.showTerminal') }}
</button>
</div>
</div>
<!-- Terminal Output (positioned at bottom when manually toggled in error state) -->

View File

@@ -13,7 +13,7 @@ export class ComfyActionbar {
async isDocked() {
const className = await this.root.getAttribute('class')
return className?.includes('static') ?? false
return className?.includes('is-docked') ?? false
}
}

View File

@@ -301,9 +301,7 @@ test.describe('Settings', () => {
})
test.describe('Support', () => {
test('Should open external zendesk link with OSS tag', async ({
comfyPage
}) => {
test('Should open external zendesk link', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
const pagePromise = comfyPage.page.context().waitForEvent('page')
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Support'])
@@ -311,10 +309,6 @@ test.describe('Support', () => {
await newPage.waitForLoadState('networkidle')
await expect(newPage).toHaveURL(/.*support\.comfy\.org.*/)
const url = new URL(newPage.url())
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
await newPage.close()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -23,10 +23,10 @@ test.describe('Vue Nodes - LOD', () => {
const vueNodesContainer = comfyPage.vueNodes.nodes
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
const buttonsInNodes = vueNodesContainer.getByRole('button')
await expect(textboxesInNodes.first()).toBeVisible()
await expect(comboboxesInNodes.first()).toBeVisible()
await expect(buttonsInNodes.first()).toBeVisible()
await comfyPage.zoom(120, 10)
await comfyPage.nextFrame()
@@ -34,7 +34,7 @@ test.describe('Vue Nodes - LOD', () => {
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
await expect(textboxesInNodes.first()).toBeHidden()
await expect(comboboxesInNodes.first()).toBeHidden()
await expect(buttonsInNodes.first()).toBeHidden()
await comfyPage.zoom(-120, 10)
await comfyPage.nextFrame()
@@ -43,6 +43,6 @@ test.describe('Vue Nodes - LOD', () => {
'vue-nodes-lod-inactive.png'
)
await expect(textboxesInNodes.first()).toBeVisible()
await expect(comboboxesInNodes.first()).toBeVisible()
await expect(buttonsInNodes.first()).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -60,7 +60,6 @@ export default defineConfig([
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
'packages/registry-types/src/comfyRegistryTypes.ts',
'public/auth-sw.js',
'src/extensions/core/*',
'src/scripts/*',
'src/types/generatedManagerTypes.ts',

13
global.d.ts vendored
View File

@@ -5,19 +5,6 @@ declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
interface Window {
__CONFIG__: {
mixpanel_token?: string
subscription_required?: boolean
server_health_alert?: {
message: string
tooltip?: string
severity?: 'info' | 'warning' | 'error'
badge?: string
}
}
}
interface Navigator {
/**
* Used by the electron API. This is a WICG non-standard API, but is guaranteed to exist in Electron.

View File

@@ -12,10 +12,6 @@ const config: KnipConfig = {
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}', '*.{js,ts,mts}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
@@ -34,16 +30,16 @@ const config: KnipConfig = {
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
'@primevue/icons',
// Dev
'@trivago/prettier-plugin-sort-imports'
],
ignore: [
// Auto generated manager types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Service worker - registered at runtime via navigator.serviceWorker.register()
'public/auth-sw.js'
'src/scripts/ui/components/splitButton.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -8,10 +8,8 @@ export default {
}
function formatAndEslint(fileNames) {
// Convert absolute paths to relative paths for better ESLint resolution
const relativePaths = fileNames.map((f) => f.replace(process.cwd() + '/', ''))
return [
`pnpm exec eslint --cache --fix ${relativePaths.join(' ')}`,
`pnpm exec prettier --cache --write ${relativePaths.join(' ')}`
`pnpm exec eslint --cache --fix ${fileNames.join(' ')}`,
`pnpm exec prettier --cache --write ${fileNames.join(' ')}`
]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.30.2",
"version": "1.30.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -43,6 +43,7 @@
"test:browser": "pnpm exec nx e2e",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"workflow:docs": "tsx scripts/cicd/generate-workflow-docs.ts",
"zipdist": "node scripts/zipdist.js"
},
"devDependencies": {
@@ -55,7 +56,6 @@
"@nx/vite": "catalog:",
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@prettier/plugin-oxc": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
@@ -90,7 +90,6 @@
"knip": "catalog:",
"lint-staged": "catalog:",
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"nx": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
@@ -116,6 +115,7 @@
"vue-component-type-helpers": "catalog:",
"vue-eslint-parser": "catalog:",
"vue-tsc": "catalog:",
"yaml": "catalog:",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "catalog:"
},

View File

@@ -200,7 +200,7 @@
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-stone-100);
--text-primary: var(--color-charcoal-700);
--input-surface: rgb(0 0 0 / 0.15);
--input-surface: rgba(0, 0, 0, 0.15);
}
.dark-theme {
@@ -247,7 +247,7 @@
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-pure-white);
--input-surface: rgb(130 130 130 / 0.1);
--input-surface: rgba(130, 130, 130, 0.1);
}
@theme inline {
@@ -258,15 +258,9 @@
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(
--interface-menu-component-surface-hovered
);
--color-interface-menu-component-surface-selected: var(
--interface-menu-component-surface-selected
);
--color-interface-menu-keybind-surface-default: var(
--interface-menu-keybind-surface-default
);
--color-interface-menu-component-surface-hovered: var(--interface-menu-component-surface-hovered);
--color-interface-menu-component-surface-selected: var(--interface-menu-component-surface-selected);
--color-interface-menu-keybind-surface-default: var(--interface-menu-keybind-surface-default);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
@@ -330,42 +324,7 @@
}
}
/* ===================== Scrollbar Utilities (Tailwind) =====================
Usage: Add `scrollbar-custom` class to scrollable containers.
The scrollbar styling adapts to light/dark theme automatically.
============================================================================ */
@utility scrollbar-custom {
overflow-y: auto;
/* Firefox */
scrollbar-width: thin;
scrollbar-color: var(--dialog-surface) transparent;
/* WebKit */
&::-webkit-scrollbar {
width: 10px;
height: 10px;
background-color: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--dialog-surface);
border-radius: 9999px;
border: 2px solid transparent;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--dialog-surface);
}
&::-webkit-scrollbar-corner {
background: transparent;
}
}
/* =================== End Custom Scrollbar (cross-browser) =================== */
/* Everthing below here to be cleaned up over time. */
/* Everything below here to be cleaned up over time. */
body {
width: 100vw;
@@ -1180,7 +1139,7 @@ audio.comfy-audio.empty-audio-widget {
}
.isLOD .lg-node-header {
border-radius: 0;
border-radius: 0px;
pointer-events: none;
}

252
pnpm-lock.yaml generated
View File

@@ -45,9 +45,6 @@ catalogs:
'@playwright/test':
specifier: ^1.52.0
version: 1.52.0
'@prettier/plugin-oxc':
specifier: ^0.0.4
version: 0.0.4
'@primeuix/forms':
specifier: 0.0.2
version: 0.0.2
@@ -189,9 +186,6 @@ catalogs:
markdown-table:
specifier: ^3.0.4
version: 3.0.4
mixpanel-browser:
specifier: ^2.71.0
version: 2.71.0
nx:
specifier: 21.4.1
version: 21.4.1
@@ -285,6 +279,9 @@ catalogs:
vuefire:
specifier: ^3.2.1
version: 3.2.1
yaml:
specifier: ^2.8.1
version: 2.8.1
yjs:
specifier: ^13.6.27
version: 13.6.27
@@ -501,9 +498,6 @@ importers:
'@playwright/test':
specifier: 'catalog:'
version: 1.52.0
'@prettier/plugin-oxc':
specifier: 'catalog:'
version: 0.0.4
'@storybook/addon-docs':
specifier: 'catalog:'
version: 9.1.1(@types/react@19.1.9)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
@@ -606,9 +600,6 @@ importers:
markdown-table:
specifier: 'catalog:'
version: 3.0.4
mixpanel-browser:
specifier: 'catalog:'
version: 2.71.0
nx:
specifier: 'catalog:'
version: 21.4.1
@@ -684,6 +675,9 @@ importers:
vue-tsc:
specifier: 'catalog:'
version: 3.0.7(typescript@5.9.2)
yaml:
specifier: 'catalog:'
version: 2.8.1
zip-dir:
specifier: ^2.0.0
version: 2.0.0
@@ -2213,21 +2207,6 @@ packages:
'@microsoft/tsdoc@0.15.1':
resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==}
'@mixpanel/rrdom@2.0.0-alpha.18.2':
resolution: {integrity: sha512-vX/tbnS14ZzzatC7vOyvAm9tOLU8tof0BuppBlphzEx1YHTSw8DQiAmyAc0AmXidchLV0W+cUHV/WsehPLh2hQ==}
'@mixpanel/rrweb-snapshot@2.0.0-alpha.18.2':
resolution: {integrity: sha512-2kSnjZZ3QZ9zOz/isOt8s54mXUUDgXk/u0eEi/rE0xBWDeuA0NHrBcqiMc+w4F/yWWUpo5F5zcuPeYpc6ufAsw==}
'@mixpanel/rrweb-types@2.0.0-alpha.18.2':
resolution: {integrity: sha512-ucIYe1mfJ2UksvXW+d3bOySTB2/0yUSqQJlUydvbBz6OO2Bhq3nJHyLXV9ExkgUMZm1ZyDcvvmNUd1+5tAXlpA==}
'@mixpanel/rrweb-utils@2.0.0-alpha.18.2':
resolution: {integrity: sha512-OomKIB6GTx5xvCLJ7iic2khT/t/tnCJUex13aEqsbSqIT/UzUUsqf+LTrgUK5ex+f6odmkCNjre2y5jvpNqn+g==}
'@mixpanel/rrweb@2.0.0-alpha.18.2':
resolution: {integrity: sha512-J3dVTEu6Z4p8di7y9KKvUooNuBjX97DdG6XGWoPEPi07A9512h9M8MEtvlY3mK0PGfuC0Mz5Pv/Ws6gjGYfKQg==}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -2354,98 +2333,6 @@ packages:
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@oxc-parser/binding-android-arm64@0.74.0':
resolution: {integrity: sha512-lgq8TJq22eyfojfa2jBFy2m66ckAo7iNRYDdyn9reXYA3I6Wx7tgGWVx1JAp1lO+aUiqdqP/uPlDaETL9tqRcg==}
engines: {node: '>=20.0.0'}
cpu: [arm64]
os: [android]
'@oxc-parser/binding-darwin-arm64@0.74.0':
resolution: {integrity: sha512-xbY/io/hkARggbpYEMFX6CwFzb7f4iS6WuBoBeZtdqRWfIEi7sm/uYWXfyVeB8uqOATvJ07WRFC2upI8PSI83g==}
engines: {node: '>=20.0.0'}
cpu: [arm64]
os: [darwin]
'@oxc-parser/binding-darwin-x64@0.74.0':
resolution: {integrity: sha512-FIj2gAGtFaW0Zk+TnGyenMUoRu1ju+kJ/h71D77xc1owOItbFZFGa+4WSVck1H8rTtceeJlK+kux+vCjGFCl9Q==}
engines: {node: '>=20.0.0'}
cpu: [x64]
os: [darwin]
'@oxc-parser/binding-freebsd-x64@0.74.0':
resolution: {integrity: sha512-W1I+g5TJg0TRRMHgEWNWsTIfe782V3QuaPgZxnfPNmDMywYdtlzllzclBgaDq6qzvZCCQc/UhvNb37KWTCTj8A==}
engines: {node: '>=20.0.0'}
cpu: [x64]
os: [freebsd]
'@oxc-parser/binding-linux-arm-gnueabihf@0.74.0':
resolution: {integrity: sha512-gxqkyRGApeVI8dgvJ19SYe59XASW3uVxF1YUgkE7peW/XIg5QRAOVTFKyTjI9acYuK1MF6OJHqx30cmxmZLtiQ==}
engines: {node: '>=20.0.0'}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm-musleabihf@0.74.0':
resolution: {integrity: sha512-jpnAUP4Fa93VdPPDzxxBguJmldj/Gpz7wTXKFzpAueqBMfZsy9KNC+0qT2uZ9HGUDMzNuKw0Se3bPCpL/gfD2Q==}
engines: {node: '>=20.0.0'}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm64-gnu@0.74.0':
resolution: {integrity: sha512-fcWyM7BNfCkHqIf3kll8fJctbR/PseL4RnS2isD9Y3FFBhp4efGAzhDaxIUK5GK7kIcFh1P+puIRig8WJ6IMVQ==}
engines: {node: '>=20.0.0'}
cpu: [arm64]
os: [linux]
'@oxc-parser/binding-linux-arm64-musl@0.74.0':
resolution: {integrity: sha512-AMY30z/C77HgiRRJX7YtVUaelKq1ex0aaj28XoJu4SCezdS8i0IftUNTtGS1UzGjGZB8zQz5SFwVy4dRu4GLwg==}
engines: {node: '>=20.0.0'}
cpu: [arm64]
os: [linux]
'@oxc-parser/binding-linux-riscv64-gnu@0.74.0':
resolution: {integrity: sha512-/RZAP24TgZo4vV/01TBlzRqs0R7E6xvatww4LnmZEBBulQBU/SkypDywfriFqWuFoa61WFXPV7sLcTjJGjim/w==}
engines: {node: '>=20.0.0'}
cpu: [riscv64]
os: [linux]
'@oxc-parser/binding-linux-s390x-gnu@0.74.0':
resolution: {integrity: sha512-620J1beNAlGSPBD+Msb3ptvrwxu04B8iULCH03zlf0JSLy/5sqlD6qBs0XUVkUJv1vbakUw1gfVnUQqv0UTuEg==}
engines: {node: '>=20.0.0'}
cpu: [s390x]
os: [linux]
'@oxc-parser/binding-linux-x64-gnu@0.74.0':
resolution: {integrity: sha512-WBFgQmGtFnPNzHyLKbC1wkYGaRIBxXGofO0+hz1xrrkPgbxbJS1Ukva1EB8sPaVBBQ52Bdc2GjLSp721NWRvww==}
engines: {node: '>=20.0.0'}
cpu: [x64]
os: [linux]
'@oxc-parser/binding-linux-x64-musl@0.74.0':
resolution: {integrity: sha512-y4mapxi0RGqlp3t6Sm+knJlAEqdKDYrEue2LlXOka/F2i4sRN0XhEMPiSOB3ppHmvK4I2zY2XBYTsX1Fel0fAg==}
engines: {node: '>=20.0.0'}
cpu: [x64]
os: [linux]
'@oxc-parser/binding-wasm32-wasi@0.74.0':
resolution: {integrity: sha512-yDS9bRDh5ymobiS2xBmjlrGdUuU61IZoJBaJC5fELdYT5LJNBXlbr3Yc6m2PWfRJwkH6Aq5fRvxAZ4wCbkGa8w==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@oxc-parser/binding-win32-arm64-msvc@0.74.0':
resolution: {integrity: sha512-XFWY52Rfb4N5wEbMCTSBMxRkDLGbAI9CBSL24BIDywwDJMl31gHEVlmHdCDRoXAmanCI6gwbXYTrWe0HvXJ7Aw==}
engines: {node: '>=20.0.0'}
cpu: [arm64]
os: [win32]
'@oxc-parser/binding-win32-x64-msvc@0.74.0':
resolution: {integrity: sha512-1D3x6iU2apLyfTQHygbdaNbX3nZaHu4yaXpD7ilYpoLo7f0MX0tUuoDrqJyJrVGqvyXgc0uz4yXz9tH9ZZhvvg==}
engines: {node: '>=20.0.0'}
cpu: [x64]
os: [win32]
'@oxc-project/types@0.74.0':
resolution: {integrity: sha512-KOw/RZrVlHGhCXh1RufBFF7Nuo7HdY5w1lRJukM/igIl6x9qtz8QycDvZdzb4qnHO7znrPyo2sJrFJK2eKHgfQ==}
'@oxc-resolver/binding-android-arm-eabi@11.6.1':
resolution: {integrity: sha512-Ma/kg29QJX1Jzelv0Q/j2iFuUad1WnjgPjpThvjqPjpOyLjCUaiFCCnshhmWjyS51Ki1Iol3fjf1qAzObf8GIA==}
cpu: [arm]
@@ -2579,10 +2466,6 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@prettier/plugin-oxc@0.0.4':
resolution: {integrity: sha512-UGXe+g/rSRbglL0FOJiar+a+nUrst7KaFmsg05wYbKiInGWP6eAj/f8A2Uobgo5KxEtb2X10zeflNH6RK2xeIQ==}
engines: {node: '>=14'}
'@primeuix/forms@0.0.2':
resolution: {integrity: sha512-DpecPQd/Qf/kav4LKCaIeGuT3AkwhJzuHCkLANTVlN/zBvo8KIj3OZHsCkm0zlIMVVnaJdtx1ULNlRQdudef+A==}
engines: {node: '>=12.11.0'}
@@ -3143,9 +3026,6 @@ packages:
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
'@types/css-font-loading-module@0.0.7':
resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -3644,9 +3524,6 @@ packages:
'@webgpu/types@0.1.51':
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
'@xstate/fsm@1.6.5':
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
@@ -3929,10 +3806,6 @@ packages:
balanced-match@2.0.0:
resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -6120,9 +5993,6 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mixpanel-browser@2.71.0:
resolution: {integrity: sha512-jKmDXe68/oQFgk/9ns9Z36bA0CJ31PH8Y77XTLLGfJvhsUPbvu+7Se9e281NejZF6+OMqx7cE+zFxToozYyNrA==}
mkdirp@3.0.1:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'}
@@ -6309,10 +6179,6 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
oxc-parser@0.74.0:
resolution: {integrity: sha512-2tDN/ttU8WE6oFh8EzKNam7KE7ZXSG5uXmvX85iNzxdJfMssDWcj3gpYzZi1E04XuE7m3v1dVWl/8BE886vPGw==}
engines: {node: '>=20.0.0'}
oxc-resolver@11.6.1:
resolution: {integrity: sha512-WQgmxevT4cM5MZ9ioQnEwJiHpPzbvntV5nInGAKo9NQZzegcOonHvcVcnkYqld7bTG35UFHEKeF7VwwsmA3cZg==}
@@ -9639,29 +9505,6 @@ snapshots:
'@microsoft/tsdoc@0.15.1': {}
'@mixpanel/rrdom@2.0.0-alpha.18.2':
dependencies:
'@mixpanel/rrweb-snapshot': 2.0.0-alpha.18.2
'@mixpanel/rrweb-snapshot@2.0.0-alpha.18.2':
dependencies:
postcss: 8.5.6
'@mixpanel/rrweb-types@2.0.0-alpha.18.2': {}
'@mixpanel/rrweb-utils@2.0.0-alpha.18.2': {}
'@mixpanel/rrweb@2.0.0-alpha.18.2':
dependencies:
'@mixpanel/rrdom': 2.0.0-alpha.18.2
'@mixpanel/rrweb-snapshot': 2.0.0-alpha.18.2
'@mixpanel/rrweb-types': 2.0.0-alpha.18.2
'@mixpanel/rrweb-utils': 2.0.0-alpha.18.2
'@types/css-font-loading-module': 0.0.7
'@xstate/fsm': 1.6.5
base64-arraybuffer: 1.0.2
mitt: 3.0.1
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.4.5
@@ -9902,55 +9745,6 @@ snapshots:
'@one-ini/wasm@0.1.1': {}
'@oxc-parser/binding-android-arm64@0.74.0':
optional: true
'@oxc-parser/binding-darwin-arm64@0.74.0':
optional: true
'@oxc-parser/binding-darwin-x64@0.74.0':
optional: true
'@oxc-parser/binding-freebsd-x64@0.74.0':
optional: true
'@oxc-parser/binding-linux-arm-gnueabihf@0.74.0':
optional: true
'@oxc-parser/binding-linux-arm-musleabihf@0.74.0':
optional: true
'@oxc-parser/binding-linux-arm64-gnu@0.74.0':
optional: true
'@oxc-parser/binding-linux-arm64-musl@0.74.0':
optional: true
'@oxc-parser/binding-linux-riscv64-gnu@0.74.0':
optional: true
'@oxc-parser/binding-linux-s390x-gnu@0.74.0':
optional: true
'@oxc-parser/binding-linux-x64-gnu@0.74.0':
optional: true
'@oxc-parser/binding-linux-x64-musl@0.74.0':
optional: true
'@oxc-parser/binding-wasm32-wasi@0.74.0':
dependencies:
'@napi-rs/wasm-runtime': 0.2.12
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.74.0':
optional: true
'@oxc-parser/binding-win32-x64-msvc@0.74.0':
optional: true
'@oxc-project/types@0.74.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.6.1':
optional: true
@@ -10046,10 +9840,6 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@prettier/plugin-oxc@0.0.4':
dependencies:
oxc-parser: 0.74.0
'@primeuix/forms@0.0.2':
dependencies:
'@primeuix/utils': 0.3.2
@@ -10603,8 +10393,6 @@ snapshots:
dependencies:
'@types/deep-eql': 4.0.2
'@types/css-font-loading-module@0.0.7': {}
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
@@ -11198,8 +10986,6 @@ snapshots:
'@webgpu/types@0.1.51': {}
'@xstate/fsm@1.6.5': {}
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
@@ -11516,8 +11302,6 @@ snapshots:
balanced-match@2.0.0: {}
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {}
better-opn@3.0.2:
@@ -14086,10 +13870,6 @@ snapshots:
mitt@3.0.1: {}
mixpanel-browser@2.71.0:
dependencies:
'@mixpanel/rrweb': 2.0.0-alpha.18.2
mkdirp@3.0.1: {}
mlly@1.8.0:
@@ -14332,26 +14112,6 @@ snapshots:
safe-push-apply: 1.0.0
optional: true
oxc-parser@0.74.0:
dependencies:
'@oxc-project/types': 0.74.0
optionalDependencies:
'@oxc-parser/binding-android-arm64': 0.74.0
'@oxc-parser/binding-darwin-arm64': 0.74.0
'@oxc-parser/binding-darwin-x64': 0.74.0
'@oxc-parser/binding-freebsd-x64': 0.74.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.74.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.74.0
'@oxc-parser/binding-linux-arm64-gnu': 0.74.0
'@oxc-parser/binding-linux-arm64-musl': 0.74.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.74.0
'@oxc-parser/binding-linux-s390x-gnu': 0.74.0
'@oxc-parser/binding-linux-x64-gnu': 0.74.0
'@oxc-parser/binding-linux-x64-musl': 0.74.0
'@oxc-parser/binding-wasm32-wasi': 0.74.0
'@oxc-parser/binding-win32-arm64-msvc': 0.74.0
'@oxc-parser/binding-win32-x64-msvc': 0.74.0
oxc-resolver@11.6.1:
dependencies:
napi-postinstall: 0.3.3

View File

@@ -16,7 +16,6 @@ catalog:
'@nx/vite': 21.4.1
'@pinia/testing': ^0.1.5
'@playwright/test': ^1.52.0
'@prettier/plugin-oxc': ^0.0.4
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
'@primeuix/utils': ^0.3.2
@@ -95,11 +94,11 @@ catalog:
vue-router: ^4.4.3
vue-tsc: ^3.0.7
vuefire: ^3.2.1
yaml: ^2.8.1
yjs: ^13.6.27
zod: ^3.23.8
zod-to-json-schema: ^3.24.1
zod-validation-error: ^3.3.0
mixpanel-browser: ^2.71.0
cleanupUnusedCatalogs: true

View File

@@ -1,147 +0,0 @@
/**
* @fileoverview Authentication Service Worker
* Intercepts /api/view requests and adds Firebase authentication headers.
* Required for browser-native requests (img, video, audio) that cannot send custom headers.
*/
/**
* @typedef {Object} AuthHeader
* @property {string} Authorization - Bearer token for authentication
*/
/**
* @typedef {Object} CachedAuth
* @property {AuthHeader|null} header
* @property {number} expiresAt - Timestamp when cache expires
*/
const CACHE_TTL_MS = 50 * 60 * 1000 // 50 minutes (Firebase tokens expire in 1 hour)
/** @type {CachedAuth|null} */
let authCache = null
/** @type {Promise<AuthHeader|null>|null} */
let authRequestInFlight = null
self.addEventListener('message', (event) => {
if (event.data.type === 'INVALIDATE_AUTH_HEADER') {
authCache = null
authRequestInFlight = null
}
})
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (
!url.pathname.startsWith('/api/view') &&
!url.pathname.startsWith('/api/viewvideo')
) {
return
}
event.respondWith(
(async () => {
try {
const authHeader = await getAuthHeader()
if (!authHeader) {
return fetch(event.request)
}
const headers = new Headers(event.request.headers)
for (const [key, value] of Object.entries(authHeader)) {
headers.set(key, value)
}
return fetch(
new Request(event.request.url, {
method: event.request.method,
headers: headers,
mode: 'same-origin',
credentials: event.request.credentials,
cache: 'no-store',
redirect: event.request.redirect,
referrer: event.request.referrer,
integrity: event.request.integrity
})
)
} catch (error) {
console.error('[Auth SW] Request failed:', error)
return fetch(event.request)
}
})()
)
})
/**
* Gets auth header from cache or requests from main thread
* @returns {Promise<AuthHeader|null>}
*/
async function getAuthHeader() {
// Return cached value if valid
if (authCache && authCache.expiresAt > Date.now()) {
return authCache.header
}
// Clear expired cache
if (authCache) {
authCache = null
}
// Deduplicate concurrent requests
if (authRequestInFlight) {
return authRequestInFlight
}
authRequestInFlight = requestAuthHeaderFromMainThread()
const header = await authRequestInFlight
authRequestInFlight = null
// Cache the result
if (header) {
authCache = {
header,
expiresAt: Date.now() + CACHE_TTL_MS
}
}
return header
}
/**
* Requests auth header from main thread via MessageChannel
* @returns {Promise<AuthHeader|null>}
*/
async function requestAuthHeaderFromMainThread() {
const clients = await self.clients.matchAll()
if (clients.length === 0) {
return null
}
const messageChannel = new MessageChannel()
return new Promise((resolve) => {
let timeoutId
messageChannel.port1.onmessage = (event) => {
clearTimeout(timeoutId)
resolve(event.data.authHeader)
}
timeoutId = setTimeout(() => {
console.error(
'[Auth SW] Timeout waiting for auth header from main thread'
)
resolve(null)
}, 1000)
clients[0].postMessage({ type: 'REQUEST_AUTH_HEADER' }, [
messageChannel.port2
])
})
}
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})

View File

@@ -18,81 +18,44 @@
export const BUNDLE_CATEGORIES = [
{
name: 'App Entry Points',
description: 'Main entry bundles and manifests',
patterns: [/^index-.*\.js$/i, /^manifest-.*\.js$/i],
description: 'Main application bundles',
patterns: [/^index-.*\.js$/],
order: 1
},
{
name: 'Graph Workspace',
description: 'Graph editor runtime, canvas, workflow orchestration',
patterns: [
/Graph(View|State)?-.*\.js$/i,
/(Canvas|Workflow|History|NodeGraph|Compositor)-.*\.js$/i
],
name: 'Core Views',
description: 'Major application views and screens',
patterns: [/GraphView-.*\.js$/, /UserSelectView-.*\.js$/],
order: 2
},
{
name: 'Views & Navigation',
description: 'Top-level views, pages, and routed surfaces',
patterns: [/.*(View|Page|Layout|Screen|Route)-.*\.js$/i],
name: 'UI Panels',
description: 'Settings and configuration panels',
patterns: [/.*Panel-.*\.js$/],
order: 3
},
{
name: 'Panels & Settings',
description: 'Configuration panels, inspectors, and settings screens',
patterns: [/.*(Panel|Settings|Config|Preferences|Manager)-.*\.js$/i],
name: 'UI Components',
description: 'Reusable UI components',
patterns: [/Avatar-.*\.js$/, /Badge-.*\.js$/],
order: 4
},
{
name: 'User & Accounts',
description: 'Authentication, profile, and account management bundles',
patterns: [
/.*((User(Panel|Select|Auth|Account|Profile|Settings|Preferences|Manager|List|Menu|Modal))|Account|Auth|Profile|Login|Signup|Password).*-.+\.js$/i
],
name: 'Services',
description: 'Business logic and services',
patterns: [/.*Service-.*\.js$/, /.*Store-.*\.js$/],
order: 5
},
{
name: 'Editors & Dialogs',
description: 'Modals, dialogs, drawers, and in-app editors',
patterns: [/.*(Modal|Dialog|Drawer|Editor)-.*\.js$/i],
name: 'Utilities',
description: 'Helper functions and utilities',
patterns: [/.*[Uu]til.*\.js$/],
order: 6
},
{
name: 'UI Components',
description: 'Reusable component library chunks',
patterns: [
/.*(Button|Avatar|Badge|Dropdown|Tabs|Table|List|Card|Form|Input|Toggle|Menu|Toolbar|Sidebar)-.*\.js$/i,
/.*\.vue_vue_type_script_setup_true_lang-.*\.js$/i
],
order: 7
},
{
name: 'Data & Services',
description: 'Stores, services, APIs, and repositories',
patterns: [/.*(Service|Store|Api|Repository)-.*\.js$/i],
order: 8
},
{
name: 'Utilities & Hooks',
description: 'Helpers, composables, and utility bundles',
patterns: [
/.*(Util|Utils|Helper|Composable|Hook)-.*\.js$/i,
/use[A-Z].*\.js$/
],
order: 9
},
{
name: 'Vendor & Third-Party',
description: 'External libraries and shared vendor chunks',
patterns: [
/^(chunk|vendor|prime|three|lodash|chart|firebase|yjs|axios|uuid)-.*\.js$/i
],
order: 10
},
{
name: 'Other',
description: 'Bundles that do not match a named category',
patterns: [/.*/],
description: 'Uncategorized bundles',
patterns: [/.*/], // Catch-all pattern
order: 99
}
]

View File

@@ -0,0 +1,450 @@
#!/usr/bin/env tsx
/**
* Generate workflow documentation from GitHub Actions workflow files
*
* This script:
* 1. Scans all workflow YAML files in .github/workflows
* 2. Extracts metadata (name, description, triggers, labels)
* 3. Updates the workflows README.md with current information
*/
import { readFileSync, readdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { parse } from 'yaml'
interface WorkflowMetadata {
filename: string
name: string
description?: string
prefix: string
triggers: string[]
labelTriggers: string[]
}
interface WorkflowsByPrefix {
[prefix: string]: {
description: string
workflows: WorkflowMetadata[]
}
}
const WORKFLOWS_DIR = join(process.cwd(), '.github/workflows')
const README_PATH = join(WORKFLOWS_DIR, 'README.md')
// Category descriptions for workflow prefixes
const PREFIX_DESCRIPTIONS: Record<string, string> = {
'ci-': 'Testing, linting, validation',
'release-': 'Version management, publishing',
'pr-': 'PR automation (triggered by labels)',
'api-': 'External API type generation',
'i18n-': 'Internationalization updates',
'publish-': 'Publishing and deployment',
'version-': 'Version management'
}
/**
* Add a label to the list if it's not already present
*/
function addUniqueLabel(labels: string[], label: string): void {
if (!labels.includes(label)) {
labels.push(label)
}
}
/**
* Extract label triggers from workflow content
*/
function extractLabelTriggers(content: string, workflowData: any): string[] {
const labels: string[] = []
// Check for label_trigger in anthropics/claude-code-action
const labelTriggerMatch = content.match(/label_trigger:\s*["']([^"']+)["']/i)
if (labelTriggerMatch) {
labels.push(labelTriggerMatch[1])
}
// Check for github.event.label.name == 'label-name' pattern
const labelNameMatches = content.matchAll(
/github\.event\.label\.name\s*==\s*['"]([^'"]+)['"]/gi
)
for (const match of labelNameMatches) {
addUniqueLabel(labels, match[1])
}
// Check for contains(github.event.pull_request.labels.*.name, 'label-name') pattern
const containsLabelMatches = content.matchAll(
/contains\(github\.event\.pull_request\.labels\.\*\.name,\s*['"]([^'"]+)['"]\)/gi
)
for (const match of containsLabelMatches) {
addUniqueLabel(labels, match[1])
}
// Check for startsWith patterns with comment commands (e.g., /update-playwright)
// These are included as they can trigger workflows through PR comments
const labelCommentMatches = content.matchAll(
/startsWith\(github\.event\.comment\.body,\s*['"]([^'"]+)['"]\)/gi
)
for (const match of labelCommentMatches) {
const command = match[1]
if (command) {
addUniqueLabel(labels, command)
}
}
return labels
}
/**
* Extract trigger information from workflow
*/
function extractTriggers(workflowData: any): string[] {
const triggers: string[] = []
const on = workflowData.on
if (!on) return triggers
if (typeof on === 'string') {
triggers.push(on)
} else if (Array.isArray(on)) {
triggers.push(...on)
} else if (typeof on === 'object') {
// Handle workflow_dispatch
if (on.workflow_dispatch !== undefined) {
triggers.push('workflow_dispatch (manual)')
}
// Handle pull_request with types
if (on.pull_request) {
if (typeof on.pull_request === 'object' && on.pull_request.types) {
const types = Array.isArray(on.pull_request.types)
? on.pull_request.types.join(', ')
: on.pull_request.types
triggers.push(`pull_request (${types})`)
} else {
triggers.push('pull_request')
}
}
// Handle pull_request_target
if (on.pull_request_target) {
if (
typeof on.pull_request_target === 'object' &&
on.pull_request_target.types
) {
const types = Array.isArray(on.pull_request_target.types)
? on.pull_request_target.types.join(', ')
: on.pull_request_target.types
triggers.push(`pull_request_target (${types})`)
} else {
triggers.push('pull_request_target')
}
}
// Handle push
if (on.push) {
triggers.push('push')
}
// Handle schedule
if (on.schedule) {
triggers.push('schedule')
}
// Handle issue_comment
if (on.issue_comment) {
if (typeof on.issue_comment === 'object' && on.issue_comment.types) {
const types = Array.isArray(on.issue_comment.types)
? on.issue_comment.types.join(', ')
: on.issue_comment.types
triggers.push(`issue_comment (${types})`)
} else {
triggers.push('issue_comment')
}
}
}
return triggers
}
/**
* Parse a single workflow file and extract metadata
*/
function parseWorkflowFile(filename: string): WorkflowMetadata | null {
try {
const filepath = join(WORKFLOWS_DIR, filename)
const content = readFileSync(filepath, 'utf-8')
const workflowData = parse(content)
if (!workflowData || !workflowData.name) {
console.warn(`Skipping ${filename}: no name field`)
return null
}
// Determine prefix from filename
const prefixMatch = filename.match(/^([a-z0-9]+)-/)
const prefix = prefixMatch ? prefixMatch[1] + '-' : 'other-'
const metadata: WorkflowMetadata = {
filename,
name: workflowData.name,
description: workflowData.description,
prefix,
triggers: extractTriggers(workflowData),
labelTriggers: extractLabelTriggers(content, workflowData)
}
return metadata
} catch (error) {
console.error(`Error parsing ${filename}:`, error)
return null
}
}
/**
* Group workflows by prefix
*/
function groupWorkflowsByPrefix(
workflows: WorkflowMetadata[]
): WorkflowsByPrefix {
const grouped: WorkflowsByPrefix = {}
for (const workflow of workflows) {
if (!grouped[workflow.prefix]) {
grouped[workflow.prefix] = {
description: PREFIX_DESCRIPTIONS[workflow.prefix] || 'Other workflows',
workflows: []
}
}
grouped[workflow.prefix].workflows.push(workflow)
}
// Sort workflows within each group by filename
for (const prefix in grouped) {
grouped[prefix].workflows.sort((a, b) =>
a.filename.localeCompare(b.filename)
)
}
return grouped
}
/**
* Generate markdown table for workflow categories
*/
function generateCategoryTable(grouped: WorkflowsByPrefix): string {
const prefixOrder = [
'ci-',
'pr-',
'release-',
'api-',
'i18n-',
'publish-',
'version-',
'other-'
]
let table =
'| Prefix | Purpose | Example |\n'
table +=
'| ---------- | ----------------------------------- | ------------------------------------ |\n'
for (const prefix of prefixOrder) {
if (grouped[prefix]) {
const example = grouped[prefix].workflows[0]?.filename || ''
const purpose = grouped[prefix].description
table += `| \`${prefix}\` | ${purpose} | \`${example}\` |\n`
}
}
return table
}
/**
* Generate detailed workflow list with descriptions
*/
function generateWorkflowList(grouped: WorkflowsByPrefix): string {
const prefixOrder = [
'ci-',
'pr-',
'release-',
'api-',
'i18n-',
'publish-',
'version-',
'other-'
]
let markdown = ''
for (const prefix of prefixOrder) {
if (!grouped[prefix]) continue
const category = grouped[prefix]
const prefixName =
prefix === 'other-'
? 'Other Workflows'
: prefix.replace('-', '').toUpperCase()
markdown += `\n### ${prefixName}\n\n`
for (const workflow of category.workflows) {
markdown += `#### [\`${workflow.filename}\`](./${workflow.filename})\n\n`
markdown += `**Name:** ${workflow.name}\n\n`
if (workflow.description) {
markdown += `**Description:** ${workflow.description}\n\n`
}
if (workflow.triggers.length > 0) {
markdown += `**Triggers:** ${workflow.triggers.join(', ')}\n\n`
}
if (workflow.labelTriggers.length > 0) {
markdown += `**Label Triggers:** \`${workflow.labelTriggers.join('`, `')}\`\n\n`
}
}
}
return markdown
}
/**
* Generate quick reference for label-triggered workflows
*/
function generateQuickReference(grouped: WorkflowsByPrefix): string {
const allWorkflows = Object.values(grouped).flatMap((g) => g.workflows)
const labelWorkflows = allWorkflows.filter((w) => w.labelTriggers.length > 0)
if (labelWorkflows.length === 0) {
return ''
}
// Group workflows by label to avoid duplicates
const labelMap = new Map<string, string[]>()
for (const workflow of labelWorkflows) {
for (const label of workflow.labelTriggers) {
// Filter out comment-based triggers (commands starting with /) from Quick Reference
// These are still shown in detailed workflow sections with full context
if (label.startsWith('/')) {
continue
}
const description = workflow.description || workflow.name
if (!labelMap.has(label)) {
labelMap.set(label, [])
}
labelMap.get(label)!.push(description)
}
}
let markdown = '## Quick Reference\n\n'
markdown +=
'For label-triggered workflows, add the corresponding label to a PR to trigger the workflow:\n'
// Sort labels alphabetically for consistency
const sortedLabels = Array.from(labelMap.keys()).sort()
for (const label of sortedLabels) {
const descriptions = labelMap.get(label)!
// Use the first description, or note if multiple workflows share the same label
const description =
descriptions.length === 1
? descriptions[0]
: `Triggers ${descriptions.length} workflows`
markdown += `- \`${label}\` - ${description}\n`
}
markdown +=
'\nFor manual workflows, use the "Run workflow" button in the Actions tab.\n'
return markdown
}
/**
* Generate the complete README content
*/
function generateReadme(grouped: WorkflowsByPrefix): string {
const categoryTable = generateCategoryTable(grouped)
const quickReference = generateQuickReference(grouped)
const workflowList = generateWorkflowList(grouped)
return `# GitHub Workflows
This directory contains GitHub Actions workflow files that automate various aspects of the ComfyUI frontend development and release process.
> **Note:** This documentation is auto-generated from workflow files. Do not edit manually.
> Run \`pnpm workflow:docs\` to regenerate.
## Naming Convention
Workflow files follow a consistent naming pattern: \`<prefix>-<descriptive-name>.yaml\`
### Category Prefixes
${categoryTable}
${quickReference}
## Workflow Details
${workflowList}
## Documentation
For more information about GitHub Actions, see:
- [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
- [Workflow syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions)
## Maintaining Workflows
### Adding a New Workflow
1. Create a new workflow file following the naming convention
2. Include \`name\` and \`description\` fields at the top of the workflow
3. Run \`pnpm workflow:docs\` to update this README
4. Commit both the workflow file and updated README
### Best Practices
1. **Always include a description**: Add a \`description\` field after the \`name\` field
2. **Use consistent prefixes**: Follow the established prefix conventions
3. **Label-triggered workflows**: For PR automation, use the \`pr-\` prefix
4. **Document triggers**: Make trigger conditions clear in the workflow description
5. **Keep docs in sync**: Run \`pnpm workflow:docs\` after any workflow changes
`
}
/**
* Main function
*/
function main() {
// Read all workflow files
const files = readdirSync(WORKFLOWS_DIR).filter(
(f) => f.endsWith('.yaml') || f.endsWith('.yml')
)
const workflowFiles = files.filter((f) => f !== 'README.md')
// Parse each workflow
const workflows: WorkflowMetadata[] = []
for (const filename of workflowFiles) {
const metadata = parseWorkflowFile(filename)
if (metadata) {
workflows.push(metadata)
}
}
// Group workflows by prefix
const grouped = groupWorkflowsByPrefix(workflows)
// Generate README
const readme = generateReadme(grouped)
writeFileSync(README_PATH, readme, 'utf-8')
// Show label-triggered workflows for validation
const labelWorkflows = workflows.filter((w) => w.labelTriggers.length > 0)
if (labelWorkflows.length > 0 && process.env.VERBOSE) {
for (const workflow of labelWorkflows) {
console.warn(
`Label-triggered: ${workflow.name}: ${workflow.labelTriggers.join(', ')}`
)
}
}
}
main()

View File

@@ -7,13 +7,6 @@ import prettyBytes from 'pretty-bytes'
import { getCategoryMetadata } from './bundle-categories.js'
/**
* @typedef {Object} SizeMetrics
* @property {number} size
* @property {number} gzip
* @property {number} brotli
*/
/**
* @typedef {Object} SizeResult
* @property {number} size
@@ -25,52 +18,15 @@ import { getCategoryMetadata } from './bundle-categories.js'
* @typedef {SizeResult & { file: string, category?: string }} BundleResult
*/
/**
* @typedef {'added' | 'removed' | 'increased' | 'decreased' | 'unchanged'} BundleStatus
*/
/**
* @typedef {Object} BundleDiff
* @property {string} fileName
* @property {BundleResult | undefined} curr
* @property {BundleResult | undefined} prev
* @property {SizeMetrics} diff
* @property {BundleStatus} status
*/
/**
* @typedef {Object} CountSummary
* @property {number} added
* @property {number} removed
* @property {number} increased
* @property {number} decreased
* @property {number} unchanged
*/
/**
* @typedef {Object} CategoryReport
* @property {string} name
* @property {string | undefined} description
* @property {number} order
* @property {{ current: SizeMetrics, baseline: SizeMetrics, diff: SizeMetrics }} metrics
* @property {CountSummary} counts
* @property {BundleDiff[]} bundles
*/
/**
* @typedef {Object} BundleReport
* @property {CategoryReport[]} categories
* @property {{ currentBundles: number, baselineBundles: number, metrics: { current: SizeMetrics, baseline: SizeMetrics, diff: SizeMetrics }, counts: CountSummary }} overall
* @property {boolean} hasBaseline
*/
const currDir = path.resolve('temp/size')
const prevDir = path.resolve('temp/size-prev')
let output = '## Bundle Size Report\n\n'
const sizeHeaders = ['Size', 'Gzip', 'Brotli']
run()
/**
* Main entry for generating the size report
* Main function to generate the size report
*/
async function run() {
if (!existsSync(currDir)) {
@@ -79,41 +35,27 @@ async function run() {
process.exit(1)
}
const report = await buildBundleReport()
const output = renderReport(report)
await renderFiles()
process.stdout.write(output)
}
/**
* Build bundle comparison data from current and baseline artifacts
* @returns {Promise<BundleReport>}
* Renders file sizes and diffs between current and previous versions
*/
async function buildBundleReport() {
async function renderFiles() {
/**
* @param {string[]} files
* @returns {string[]}
*/
const filterFiles = (files) => files.filter((file) => file.endsWith('.json'))
const currFiles = filterFiles(await readdir(currDir))
const baselineFiles = existsSync(prevDir)
? filterFiles(await readdir(prevDir))
: []
const fileList = new Set([...currFiles, ...baselineFiles])
const curr = filterFiles(await readdir(currDir))
const prev = existsSync(prevDir) ? filterFiles(await readdir(prevDir)) : []
const fileList = new Set([...curr, ...prev])
/** @type {Map<string, CategoryReport>} */
const categories = new Map()
const overall = {
currentBundles: 0,
baselineBundles: 0,
metrics: {
current: createMetrics(),
baseline: createMetrics(),
diff: createMetrics()
},
counts: createCounts()
}
// Group bundles by category
/** @type {Map<string, Array<{fileName: string, curr: BundleResult | undefined, prev: BundleResult | undefined}>>} */
const bundlesByCategory = new Map()
for (const file of fileList) {
const currPath = path.resolve(currDir, file)
@@ -121,440 +63,100 @@ async function buildBundleReport() {
const curr = await importJSON(currPath)
const prev = await importJSON(prevPath)
const fileName = curr?.file || prev?.file
if (!fileName) continue
const fileName = curr?.file || prev?.file || ''
const category = curr?.category || prev?.category || 'Other'
const categoryName = curr?.category || prev?.category || 'Other'
const category = ensureCategoryEntry(categories, categoryName)
const currMetrics = toMetrics(curr)
const baselineMetrics = toMetrics(prev)
const diffMetrics = subtractMetrics(currMetrics, baselineMetrics)
const status = getStatus(curr, prev, diffMetrics.size)
if (curr) {
overall.currentBundles++
}
if (prev) {
overall.baselineBundles++
if (!bundlesByCategory.has(category)) {
bundlesByCategory.set(category, [])
}
addMetrics(overall.metrics.current, currMetrics)
addMetrics(overall.metrics.baseline, baselineMetrics)
addMetrics(overall.metrics.diff, diffMetrics)
incrementStatus(overall.counts, status)
addMetrics(category.metrics.current, currMetrics)
addMetrics(category.metrics.baseline, baselineMetrics)
addMetrics(category.metrics.diff, diffMetrics)
incrementStatus(category.counts, status)
category.bundles.push({
fileName,
curr,
prev,
diff: diffMetrics,
status
})
// @ts-expect-error - get is valid
bundlesByCategory.get(category).push({ fileName, curr, prev })
}
const sortedCategories = Array.from(categories.values()).sort(
(a, b) => a.order - b.order
)
return {
categories: sortedCategories,
overall,
hasBaseline: baselineFiles.length > 0
}
}
/**
* Render the complete report in markdown
* @param {BundleReport} report
* @returns {string}
*/
function renderReport(report) {
const parts = ['## Bundle Size Report\n']
parts.push(renderSummary(report))
if (report.categories.length > 0) {
const glance = renderCategoryGlance(report)
if (glance) {
parts.push('\n' + glance)
}
parts.push('\n' + renderCategoryDetails(report))
}
return (
parts
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n'
)
}
/**
* Render overall summary bullets
* @param {BundleReport} report
* @returns {string}
*/
function renderSummary(report) {
const { overall, hasBaseline } = report
const lines = ['**Summary**']
const rawLineParts = [
`- Raw size: ${prettyBytes(overall.metrics.current.size)}`
]
if (hasBaseline) {
rawLineParts.push(`baseline ${prettyBytes(overall.metrics.baseline.size)}`)
rawLineParts.push(`${formatDiffIndicator(overall.metrics.diff.size)}`)
}
lines.push(rawLineParts.join(' '))
const gzipLineParts = [`- Gzip: ${prettyBytes(overall.metrics.current.gzip)}`]
if (hasBaseline) {
gzipLineParts.push(`baseline ${prettyBytes(overall.metrics.baseline.gzip)}`)
gzipLineParts.push(`${formatDiffIndicator(overall.metrics.diff.gzip)}`)
}
lines.push(gzipLineParts.join(' '))
const brotliLineParts = [
`- Brotli: ${prettyBytes(overall.metrics.current.brotli)}`
]
if (hasBaseline) {
brotliLineParts.push(
`baseline ${prettyBytes(overall.metrics.baseline.brotli)}`
)
brotliLineParts.push(
`${formatDiffIndicator(overall.metrics.diff.brotli)}`
)
}
lines.push(brotliLineParts.join(' '))
const bundleStats = [`${overall.currentBundles} current`]
if (hasBaseline) {
bundleStats.push(`${overall.baselineBundles} baseline`)
}
const statusParts = []
if (overall.counts.added) statusParts.push(`${overall.counts.added} added`)
if (overall.counts.removed)
statusParts.push(`${overall.counts.removed} removed`)
if (overall.counts.increased)
statusParts.push(`${overall.counts.increased} grew`)
if (overall.counts.decreased)
statusParts.push(`${overall.counts.decreased} shrank`)
let bundlesLine = `- Bundles: ${bundleStats.join(' • ')}`
if (statusParts.length > 0) {
bundlesLine += `${statusParts.join(' / ')}`
}
lines.push(bundlesLine)
if (!hasBaseline) {
lines.push(
'_Baseline artifact not found; showing current bundle sizes only._'
)
}
return lines.join('\n')
}
/**
* Render a compact category glance line
* @param {BundleReport} report
* @returns {string}
*/
function renderCategoryGlance(report) {
const { categories, hasBaseline } = report
const relevant = categories.filter(
(category) =>
category.metrics.current.size > 0 ||
(hasBaseline && category.metrics.baseline.size > 0)
)
if (relevant.length === 0) return ''
const sorted = relevant.slice().sort((a, b) => {
if (hasBaseline) {
return (
Math.abs(b.metrics.diff.size) - Math.abs(a.metrics.diff.size) ||
b.metrics.current.size - a.metrics.current.size
)
}
return b.metrics.current.size - a.metrics.current.size
// Sort categories by their order
const sortedCategories = Array.from(bundlesByCategory.keys()).sort((a, b) => {
const metaA = getCategoryMetadata(a)
const metaB = getCategoryMetadata(b)
return (metaA?.order ?? 99) - (metaB?.order ?? 99)
})
const limit = 6
const trimmed = sorted.slice(0, limit)
const parts = trimmed.map((category) => {
const currentStr = prettyBytes(category.metrics.current.size)
if (hasBaseline) {
return `${category.name} ${formatDiffIndicator(category.metrics.diff.size)} (${currentStr})`
let totalSize = 0
let totalCount = 0
// Render each category
for (const category of sortedCategories) {
const bundles = bundlesByCategory.get(category) || []
if (bundles.length === 0) continue
const categoryMeta = getCategoryMetadata(category)
output += `### ${category}\n\n`
if (categoryMeta?.description) {
output += `_${categoryMeta.description}_\n\n`
}
return `${category.name} ${currentStr}`
})
if (sorted.length > limit) {
parts.push(`+ ${sorted.length - limit} more`)
}
const rows = []
let categorySize = 0
return `**Category Glance**\n${parts.join(' · ')}`
}
/**
* Render per-category detail tables wrapped in collapsible sections
* @param {BundleReport} report
* @returns {string}
*/
function renderCategoryDetails(report) {
const lines = ['<details>', '<summary>Per-category breakdown</summary>', '']
for (const category of report.categories) {
lines.push(renderCategoryBlock(category, report.hasBaseline))
}
lines.push('</details>')
return lines.join('\n')
}
/**
* Render a single category block with its table
* @param {CategoryReport} category
* @param {boolean} hasBaseline
* @returns {string}
*/
function renderCategoryBlock(category, hasBaseline) {
const lines = ['<details>']
const currentStr = prettyBytes(category.metrics.current.size)
const summaryParts = [`<summary>${category.name}${currentStr}`]
if (hasBaseline) {
summaryParts.push(
` (baseline ${prettyBytes(category.metrics.baseline.size)}) • ${formatDiffIndicator(category.metrics.diff.size)}`
)
}
summaryParts.push('</summary>')
lines.push(summaryParts.join(''))
if (category.description) {
lines.push(`_${category.description}_`)
}
if (category.bundles.length === 0) {
lines.push('No bundles matched this category.\n')
lines.push('</details>\n')
return lines.join('\n')
}
const headers = hasBaseline
? ['File', 'Before', 'After', 'Δ Raw', 'Δ Gzip', 'Δ Brotli']
: ['File', 'Size', 'Gzip', 'Brotli']
const rows = category.bundles
.slice()
.sort((a, b) => {
const diffMagnitude = Math.abs(b.diff.size) - Math.abs(a.diff.size)
if (diffMagnitude !== 0) return diffMagnitude
return a.fileName.localeCompare(b.fileName)
})
.map((bundle) => {
if (hasBaseline) {
return [
formatFileLabel(bundle),
formatSize(bundle.prev?.size),
formatSize(bundle.curr?.size),
formatDiffIndicator(bundle.diff.size),
formatDiffIndicator(bundle.diff.gzip),
formatDiffIndicator(bundle.diff.brotli)
]
for (const { fileName, curr, prev } of bundles) {
if (!curr) {
// File was deleted
rows.push([`~~${fileName}~~`])
} else {
rows.push([
fileName,
`${prettyBytes(curr.size)}${getDiff(curr.size, prev?.size)}`,
`${prettyBytes(curr.gzip)}${getDiff(curr.gzip, prev?.gzip)}`,
`${prettyBytes(curr.brotli)}${getDiff(curr.brotli, prev?.brotli)}`
])
categorySize += curr.size
totalSize += curr.size
totalCount++
}
}
return [
formatFileLabel(bundle),
formatSize(bundle.curr?.size),
formatSize(bundle.curr?.gzip),
formatSize(bundle.curr?.brotli)
]
// Sort rows by file name within category
rows.sort((a, b) => {
const fileA = a[0].replace(/~~/g, '')
const fileB = b[0].replace(/~~/g, '')
return fileA.localeCompare(fileB)
})
lines.push(markdownTable([headers, ...rows]))
const statusParts = []
if (category.counts.added) statusParts.push(`${category.counts.added} added`)
if (category.counts.removed)
statusParts.push(`${category.counts.removed} removed`)
if (category.counts.increased)
statusParts.push(`${category.counts.increased} grew`)
if (category.counts.decreased)
statusParts.push(`${category.counts.decreased} shrank`)
if (statusParts.length > 0) {
lines.push(`\n_Status:_ ${statusParts.join(' / ')}`)
output += markdownTable([['File', ...sizeHeaders], ...rows])
output += `\n\n**Category Total:** ${prettyBytes(categorySize)}\n\n`
}
lines.push('</details>\n')
return lines.join('\n')
}
/**
* Ensure a category entry exists in the map
* @param {Map<string, CategoryReport>} categories
* @param {string} categoryName
* @returns {CategoryReport}
*/
function ensureCategoryEntry(categories, categoryName) {
if (!categories.has(categoryName)) {
const meta = getCategoryMetadata(categoryName)
categories.set(categoryName, {
name: categoryName,
description: meta?.description,
order: meta?.order ?? 99,
metrics: {
current: createMetrics(),
baseline: createMetrics(),
diff: createMetrics()
},
counts: createCounts(),
bundles: []
})
}
// @ts-expect-error - ensured by check above
return categories.get(categoryName)
}
/**
* Convert bundle result to metrics
* @param {BundleResult | undefined} bundle
* @returns {SizeMetrics}
*/
function toMetrics(bundle) {
if (!bundle) return createMetrics()
return {
size: bundle.size,
gzip: bundle.gzip,
brotli: bundle.brotli
// Add overall summary
if (totalCount > 0) {
output += '---\n\n'
output += `**Overall Total Size:** ${prettyBytes(totalSize)}\n`
output += `**Total Bundle Count:** ${totalCount}\n`
}
}
/**
* Create an empty metrics object
* @returns {SizeMetrics}
*/
function createMetrics() {
return { size: 0, gzip: 0, brotli: 0 }
}
/**
* Add source metrics into target metrics
* @param {SizeMetrics} target
* @param {SizeMetrics} source
*/
function addMetrics(target, source) {
target.size += source.size
target.gzip += source.gzip
target.brotli += source.brotli
}
/**
* Subtract baseline metrics from current metrics
* @param {SizeMetrics} current
* @param {SizeMetrics} baseline
* @returns {SizeMetrics}
*/
function subtractMetrics(current, baseline) {
return {
size: current.size - baseline.size,
gzip: current.gzip - baseline.gzip,
brotli: current.brotli - baseline.brotli
}
}
/**
* Create an empty counts object
* @returns {CountSummary}
*/
function createCounts() {
return { added: 0, removed: 0, increased: 0, decreased: 0, unchanged: 0 }
}
/**
* Increment status counters
* @param {CountSummary} counts
* @param {BundleStatus} status
*/
function incrementStatus(counts, status) {
counts[status] += 1
}
/**
* Determine bundle status for reporting
* @param {BundleResult | undefined} curr
* @param {BundleResult | undefined} prev
* @param {number} sizeDiff
* @returns {BundleStatus}
*/
function getStatus(curr, prev, sizeDiff) {
if (curr && prev) {
if (sizeDiff > 0) return 'increased'
if (sizeDiff < 0) return 'decreased'
return 'unchanged'
}
if (curr && !prev) return 'added'
if (!curr && prev) return 'removed'
return 'unchanged'
}
/**
* Format file label with status hints
* @param {BundleDiff} bundle
* @returns {string}
*/
function formatFileLabel(bundle) {
if (bundle.status === 'added') {
return `**${bundle.fileName}** _(new)_`
}
if (bundle.status === 'removed') {
return `~~${bundle.fileName}~~ _(removed)_`
}
return bundle.fileName
}
/**
* Format size for table output
* @param {number | undefined} value
* @returns {string}
*/
function formatSize(value) {
if (value === undefined) return '—'
return prettyBytes(value)
}
/**
* Format a diff with an indicator emoji
* @param {number} diff
* @returns {string}
*/
function formatDiffIndicator(diff) {
if (diff > 0) {
return `:red_circle: +${prettyBytes(diff)}`
}
if (diff < 0) {
return `:green_circle: -${prettyBytes(Math.abs(diff))}`
}
return ':white_circle: 0 B'
}
/**
* Import JSON data if it exists
* Imports JSON data from a specified path
*
* @template T
* @param {string} filePath
* @returns {Promise<T | undefined>}
* @param {string} filePath - Path to the JSON file
* @returns {Promise<T | undefined>} The JSON content or undefined if the file does not exist
*/
async function importJSON(filePath) {
if (!existsSync(filePath)) return undefined
return (await import(filePath, { with: { type: 'json' } })).default
}
/**
* Calculates the difference between the current and previous sizes
*
* @param {number} curr - The current size
* @param {number} [prev] - The previous size
* @returns {string} The difference in pretty format
*/
function getDiff(curr, prev) {
if (prev === undefined) return ''
const diff = curr - prev
if (diff === 0) return ''
const sign = diff > 0 ? '+' : ''
return ` (**${sign}${prettyBytes(diff)}**)`
}

View File

@@ -1,64 +1,29 @@
/**
* Utility functions for downloading files
*/
import { isCloud } from '@/platform/distribution/types'
// Constants
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
/**
* Trigger a download by creating a temporary anchor element
* @param href - The URL or blob URL to download
* @param filename - The filename to suggest to the browser
*/
function triggerLinkDownload(href: string, filename: string): void {
const link = document.createElement('a')
link.href = href
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/**
* Download a file from a URL by creating a temporary anchor element
* @param url - The URL of the file to download (must be a valid URL string)
* @param filename - Optional filename override (will use URL filename or default if not provided)
* @throws {Error} If the URL is invalid or empty
*/
export function downloadFile(url: string, filename?: string): void {
export const downloadFile = (url: string, filename?: string): void => {
if (!url || typeof url !== 'string' || url.trim().length === 0) {
throw new Error('Invalid URL provided for download')
}
const inferredFilename =
const link = document.createElement('a')
link.href = url
link.download =
filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
if (isCloud) {
// Assets from cross-origin (e.g., GCS) cannot be downloaded this way
void downloadViaBlobFetch(url, inferredFilename).catch((error) => {
console.error('Failed to download file', error)
})
return
}
triggerLinkDownload(url, inferredFilename)
}
/**
* Download a Blob by creating a temporary object URL and anchor element
* @param filename - The filename to suggest to the browser
* @param blob - The Blob to download
*/
export function downloadBlob(filename: string, blob: Blob): void {
const url = URL.createObjectURL(blob)
triggerLinkDownload(url, filename)
// Revoke on the next microtask to give the browser time to start the download
queueMicrotask(() => URL.revokeObjectURL(url))
// Trigger download
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/**
@@ -74,15 +39,3 @@ const extractFilenameFromUrl = (url: string): string | null => {
return null
}
}
const downloadViaBlobFetch = async (
href: string,
filename: string
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
const blob = await response.blob()
downloadBlob(filename, blob)
}

View File

@@ -42,7 +42,7 @@
<slot name="topmenu" :sidebar-panel-visible="sidebarPanelVisible" />
<Splitter
class="splitter-overlay splitter-overlay-bottom mr-1 mb-1 ml-1 flex-1"
class="splitter-overlay splitter-overlay-bottom mr-2 mb-2 ml-2 flex-1"
layout="vertical"
:pt:gutter="
'rounded-tl-lg rounded-tr-lg ' +

View File

@@ -1,11 +1,11 @@
<template>
<div v-if="!workspaceStore.focusMode" class="ml-2 flex pt-1">
<div v-if="!workspaceStore.focusMode" class="ml-2 flex pt-2">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div
class="actionbar-container pointer-events-auto mx-1 flex h-12 items-center rounded-lg px-2 shadow-md"
class="actionbar-container pointer-events-auto mx-2 flex h-12 items-center rounded-lg px-2 shadow-md"
>
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -13,8 +13,8 @@
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<LoginButton v-if="!isLoggedIn" />
<CurrentUserButton v-else class="shrink-0" />
</div>
</div>
</template>
@@ -29,11 +29,9 @@ import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
const workspaceStore = useWorkspaceStore()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()

View File

@@ -2,7 +2,10 @@
<div class="flex h-full items-center">
<div
v-if="isDragging && !isDocked"
:class="actionbarClass"
class="actionbar-drop-zone m-1.5 flex items-center justify-center self-stretch rounded-md"
:class="{
'drop-zone-active': isMouseOverDropZone
}"
@mouseenter="onMouseEnterDropZone"
@mouseleave="onMouseLeaveDropZone"
>
@@ -10,15 +13,18 @@
</div>
<Panel
class="pointer-events-auto z-1000"
class="actionbar"
:style="style"
:class="panelClass"
:pt="{
header: { class: 'hidden' },
content: { class: isDocked ? 'p-0' : 'p-1' }
:class="{
fixed: !isDocked,
'is-dragging': isDragging,
'is-docked static mr-2 border-none bg-transparent p-0': isDocked
}"
>
<div ref="panelRef" class="flex items-center select-none">
<div
ref="panelRef"
class="actionbar-content flex items-center select-none"
>
<span
ref="dragHandleRef"
:class="
@@ -28,8 +34,7 @@
)
"
/>
<ComfyRunButton />
<ComfyQueueButton />
</div>
</Panel>
</div>
@@ -50,7 +55,7 @@ import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
import ComfyQueueButton from './ComfyQueueButton.vue'
const settingsStore = useSettingStore()
@@ -245,20 +250,45 @@ watch(isDragging, (dragging) => {
isMouseOverDropZone.value = false
}
})
const actionbarClass = computed(() =>
cn(
'w-[265px] border-dashed border-blue-500 opacity-80',
'm-1.5 flex items-center justify-center self-stretch',
'rounded-md before:w-50 before:-ml-50 before:h-full',
isMouseOverDropZone.value &&
'border-[3px] opacity-100 scale-105 shadow-[0_0_20px] shadow-blue-500'
)
)
const panelClass = computed(() =>
cn(
'actionbar pointer-events-auto z1000',
isDragging.value && 'select-none pointer-events-none',
isDocked.value ? 'p-0 static mr-2 border-none bg-transparent' : 'fixed'
)
)
</script>
<style scoped>
@reference '../../assets/css/style.css';
.actionbar {
pointer-events: all;
z-index: 1000;
}
.actionbar-drop-zone {
width: 265px;
border: 2px dashed var(--p-primary-color);
opacity: 0.8;
}
.actionbar-drop-zone.drop-zone-active {
background: var(--p-highlight-background-focus);
border-color: var(--p-primary-color);
border-width: 3px;
box-shadow: 0 0 20px var(--p-primary-color);
opacity: 1;
transform: scale(1.05);
}
.actionbar.is-dragging {
user-select: none;
pointer-events: none;
}
:deep(.p-panel-content) {
@apply p-1;
}
.is-docked :deep(.p-panel-content) {
@apply p-0;
}
:deep(.p-panel-header) {
display: none;
}
</style>

View File

@@ -8,7 +8,7 @@
showDelay: 600
}"
class="comfyui-queue-button"
:label="String(activeQueueModeMenuItem?.label ?? '')"
:label="activeQueueModeMenuItem.label"
severity="primary"
size="small"
:model="queueModeMenuItems"
@@ -33,7 +33,7 @@
value: item.tooltip,
showDelay: 600
}"
:label="String(item.label ?? '')"
:label="String(item.label)"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
size="small"
@@ -82,13 +82,10 @@
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import type { MenuItem } from 'primevue/menuitem'
import SplitButton from 'primevue/splitbutton'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import {
useQueuePendingTaskCountStore,
@@ -96,52 +93,43 @@ import {
} from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import BatchCountEdit from '../BatchCountEdit.vue'
import BatchCountEdit from './BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode } = storeToRefs(useQueueSettingsStore())
const { t } = useI18n()
const queueModeMenuItemLookup = computed(() => {
const items: Record<string, MenuItem> = {
disabled: {
key: 'disabled',
label: t('menu.run'),
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
}
},
change: {
key: 'change',
label: `${t('menu.run')} (${t('menu.onChange')})`,
tooltip: t('menu.onChangeTooltip'),
command: () => {
queueMode.value = 'change'
}
const queueModeMenuItemLookup = computed(() => ({
disabled: {
key: 'disabled',
label: t('menu.run'),
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
}
},
instant: {
key: 'instant',
label: `${t('menu.run')} (${t('menu.instant')})`,
tooltip: t('menu.instantTooltip'),
command: () => {
queueMode.value = 'instant'
}
},
change: {
key: 'change',
label: `${t('menu.run')} (${t('menu.onChange')})`,
tooltip: t('menu.onChangeTooltip'),
command: () => {
queueMode.value = 'change'
}
}
if (!isCloud) {
items.instant = {
key: 'instant',
label: `${t('menu.run')} (${t('menu.instant')})`,
tooltip: t('menu.instantTooltip'),
command: () => {
queueMode.value = 'instant'
}
}
}
return items
})
}))
const activeQueueModeMenuItem = computed(() => {
// Fallback to disabled mode if current mode is not available (e.g., instant mode in cloud)
return (
queueModeMenuItemLookup.value[queueMode.value] ||
queueModeMenuItemLookup.value.disabled
)
})
const activeQueueModeMenuItem = computed(
() => queueModeMenuItemLookup.value[queueMode.value]
)
const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup.value)
)
@@ -153,15 +141,10 @@ const hasPendingTasks = computed(
const commandStore = useCommandStore()
const queuePrompt = async (e: Event) => {
const isShiftPressed = 'shiftKey' in e && e.shiftKey
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
if (isCloud) {
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
}
const commandId =
'shiftKey' in e && e.shiftKey
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
await commandStore.execute(commandId)
}
</script>

View File

@@ -1,19 +0,0 @@
<template>
<component
:is="currentButton"
:key="isActiveSubscription ? 'queue' : 'subscribe'"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
const { isActiveSubscription } = useSubscription()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
)
</script>

View File

@@ -1,7 +0,0 @@
import { defineAsyncComponent } from 'vue'
import { isCloud } from '@/platform/distribution/types'
export default isCloud && window.__CONFIG__?.subscription_required
? defineAsyncComponent(() => import('./CloudRunButtonWrapper.vue'))
: defineAsyncComponent(() => import('./ComfyQueueButton.vue'))

View File

@@ -18,7 +18,7 @@
class="w-fit rounded-lg p-0"
:model="items"
:pt="{ item: { class: 'pointer-events-auto' } }"
:aria-label="$t('g.graphNavigation')"
aria-label="Graph navigation"
>
<template #item="{ item }">
<SubgraphBreadcrumbItem

View File

@@ -5,7 +5,6 @@
value: item.label,
showDelay: 512
}"
draggable="false"
href="#"
class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
:class="{

View File

@@ -46,12 +46,7 @@
: $t('manager.installAllMissingNodes')
"
/>
<Button
:label="$t('g.openManager')"
size="small"
outlined
@click="openManager"
/>
<Button label="Open Manager" size="small" outlined @click="openManager" />
</div>
</template>

View File

@@ -89,7 +89,7 @@
<img
src="/assets/images/comfy-logo-mono.svg"
class="mr-2 h-5 w-5"
:alt="$t('g.comfy')"
alt="Comfy"
/>
{{ t('auth.login.useApiKey') }}
</Button>

View File

@@ -89,7 +89,7 @@
ref="keybindingInput"
class="mb-2 text-center"
:model-value="newBindingKeyCombo?.toString() ?? ''"
:placeholder="$t('g.pressKeysForNewBinding')"
placeholder="Press keys for new binding"
autocomplete="off"
fluid
@keydown.stop.prevent="captureKeybinding"

View File

@@ -67,10 +67,10 @@
/>
<Button
v-if="!isApiKeyLogin"
class="w-fit"
variant="text"
class="w-32"
severity="danger"
:label="$t('auth.deleteAccount.deleteAccount')"
icon="pi pi-trash"
@click="handleDeleteAccount"
/>
</div>

View File

@@ -3,7 +3,7 @@
<div class="px-2 py-4">
<img
src="/assets/images/comfy-logo-single.svg"
:alt="$t('g.comfyOrgLogoAlt')"
alt="ComfyOrg Logo"
width="32"
height="32"
/>

View File

@@ -12,7 +12,7 @@
v-if="isNativeWindow() && workflowTabsPosition !== 'Topbar'"
class="app-drag fixed top-0 left-0 z-10 h-[var(--comfy-topbar-height)] w-full"
/>
<div class="flex h-full items-center">
<div class="flex">
<WorkflowTabs />
<TopbarBadges />
</div>
@@ -420,7 +420,9 @@ onMounted(async () => {
throw error
}
}
CORE_SETTINGS.forEach(settingStore.addSetting)
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})
await newUserService().initializeIfNewUser(settingStore)

View File

@@ -1,7 +1,7 @@
<template>
<div
v-if="visible"
class="absolute right-0 bottom-[62px] z-1300 flex w-[250px] justify-center border-0! bg-inherit!"
class="absolute right-2 bottom-[66px] z-1300 flex w-[250px] justify-center border-0! bg-inherit!"
>
<div
class="w-4/5 rounded-lg border border-node-border bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"

View File

@@ -243,7 +243,7 @@ const pt = computed(() => ({
},
listContainer: () => ({
style: { maxHeight: listMaxHeight },
class: 'scrollbar-custom'
class: 'overflow-y-auto scrollbar-hide'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'

View File

@@ -159,7 +159,7 @@ const pt = computed(() => ({
},
listContainer: () => ({
style: `max-height: ${listMaxHeight}`,
class: 'scrollbar-custom'
class: 'overflow-y-auto scrollbar-hide'
}),
list: {
class:

View File

@@ -180,7 +180,7 @@ onMounted(() => {
* but need to reference sidebar dimensions for proper positioning.
*/
:root {
--sidebar-padding: 4px;
--sidebar-padding: 8px;
--sidebar-icon-size: 1rem;
--sidebar-default-floating-width: 56px;

View File

@@ -59,7 +59,7 @@
<teleport v-if="isHovered" to="#node-library-node-preview-container">
<div class="node-lib-node-preview" :style="nodePreviewStyle">
<NodePreview :node-def="nodeDef" />
<NodePreview ref="previewRef" :node-def="nodeDef" />
</div>
</teleport>
</div>
@@ -132,12 +132,11 @@ function deleteBlueprint() {
void useSubgraphStore().deleteBlueprint(props.node.data.name)
}
const previewRef = ref<InstanceType<typeof NodePreview> | null>(null)
const nodePreviewStyle = ref<CSSProperties>({
position: 'fixed',
position: 'absolute',
top: '0px',
left: '0px',
pointerEvents: 'none',
zIndex: 1001
left: '0px'
})
const handleNodeHover = async () => {
@@ -145,15 +144,19 @@ const handleNodeHover = async () => {
if (!hoverTarget) return
const targetRect = hoverTarget.getBoundingClientRect()
const margin = 40
nodePreviewStyle.value.top = `${targetRect.top}px`
nodePreviewStyle.value.left =
sidebarLocation.value === 'left'
? `${targetRect.right + margin}px`
: `${targetRect.left - margin}px`
nodePreviewStyle.value.transform =
sidebarLocation.value === 'right' ? 'translateX(-100%)' : undefined
const previewHeight = previewRef.value?.$el.offsetHeight || 0
const availableSpaceBelow = window.innerHeight - targetRect.bottom
nodePreviewStyle.value.top =
previewHeight > availableSpaceBelow
? `${Math.max(0, targetRect.top - (previewHeight - availableSpaceBelow) - 20)}px`
: `${targetRect.top - 40}px`
if (sidebarLocation.value === 'left') {
nodePreviewStyle.value.left = `${targetRect.right}px`
} else {
nodePreviewStyle.value.left = `${targetRect.left - 400}px`
}
}
const container = ref<HTMLElement | null>(null)

View File

@@ -64,7 +64,7 @@ function updateToastPosition() {
styleElement.textContent = `
.p-toast.p-component.p-toast-top-right {
top: ${rect.top + 100}px !important;
top: ${rect.top + 20}px !important;
right: ${window.innerWidth - (rect.left + rect.width) + 20}px !important;
}
`

View File

@@ -1,83 +1,20 @@
<template>
<div
v-tooltip="badge.tooltip"
class="flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
:class="[{ 'flex-row-reverse': reverseOrder }, noPadding ? '' : 'px-3']"
:style="{ backgroundColor: 'var(--comfy-menu-bg)' }"
>
<i
v-if="iconClass"
:class="['shrink-0 text-base', iconClass, iconColorClass]"
/>
<div class="flex items-center gap-2 bg-comfy-menu-secondary px-3">
<div
v-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
:class="labelClasses"
class="rounded-full bg-white px-1.5 py-0.5 text-xxxs font-semibold text-black"
>
{{ badge.label }}
</div>
<div class="font-inter text-sm font-extrabold" :class="textClasses">
<div class="font-inter text-sm font-extrabold text-slate-100">
{{ badge.text }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { TopbarBadge } from '@/types/comfy'
const props = withDefaults(
defineProps<{
badge: TopbarBadge
reverseOrder?: boolean
noPadding?: boolean
}>(),
{
reverseOrder: false,
noPadding: false
}
)
const variant = computed(() => props.badge.variant ?? 'info')
const labelClasses = computed(() => {
switch (variant.value) {
case 'error':
return 'bg-danger-100 text-white'
case 'warning':
return 'bg-warning-100 text-black'
case 'info':
default:
return 'bg-white text-black'
}
})
const textClasses = computed(() => {
switch (variant.value) {
case 'error':
return 'text-danger-100'
case 'warning':
return 'text-warning-100'
case 'info':
default:
return 'text-slate-100'
}
})
const iconColorClass = computed(() => textClasses.value)
const iconClass = computed(() => {
if (props.badge.icon) {
return props.badge.icon
}
switch (variant.value) {
case 'error':
return 'pi pi-exclamation-circle'
case 'warning':
return 'pi pi-exclamation-triangle'
case 'info':
default:
return undefined
}
})
defineProps<{
badge: TopbarBadge
}>()
</script>

View File

@@ -1,36 +1,17 @@
<template>
<div v-if="notMobile" class="flex h-full shrink-0 items-center">
<div class="flex">
<TopbarBadge
v-for="badge in topbarBadgeStore.badges"
:key="badge.text"
:badge
:reverse-order="reverseOrder"
:no-padding="noPadding"
/>
</div>
</template>
<script lang="ts" setup>
import { useBreakpoints } from '@vueuse/core'
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
import TopbarBadge from './TopbarBadge.vue'
withDefaults(
defineProps<{
reverseOrder?: boolean
noPadding?: boolean
}>(),
{
reverseOrder: false,
noPadding: false
}
)
const BREAKPOINTS = { md: 880 }
const breakpoints = useBreakpoints(BREAKPOINTS)
const notMobile = breakpoints.greater('md')
const topbarBadgeStore = useTopbarBadgeStore()
</script>

View File

@@ -1,12 +1,10 @@
import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -56,16 +54,6 @@ export const useFirebaseAuthActions = () => {
detail: t('auth.signOut.successDetail'),
life: 5000
})
if (isCloud) {
try {
const router = useRouter()
await router.push({ name: 'cloud-login' })
} catch (error) {
// needed for local development until we bring in cloud login pages.
window.location.reload()
}
}
}, reportError)
const sendPasswordReset = wrapWithErrorHandlingAsync(
@@ -220,7 +208,6 @@ export const useFirebaseAuthActions = () => {
signUpWithEmail,
updatePassword,
deleteAccount,
accessError,
reportError
accessError
}
}

View File

@@ -126,6 +126,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
index,
linked: input.link != null
})
})
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
Object.defineProperty(node, 'widgets', {
get() {
@@ -136,15 +144,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
index,
linked: input.link != null
})
})
return (
const safeWidgets = reactiveComputed<SafeWidgetData[]>(
() =>
node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
@@ -182,8 +183,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
}) ?? []
)
})
)
const nodeType =
node.type ||

View File

@@ -1,12 +1,6 @@
import { useEventListener } from '@vueuse/core'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
const clipboardHTMLWrapper = [
'<meta charset="utf-8"><div><span data-metadata="',
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
]
/**
* Adds a handler on copy that serializes selected nodes to JSON
@@ -15,19 +9,28 @@ export const useCopy = () => {
const canvasStore = useCanvasStore()
useEventListener(document, 'copy', (e) => {
if (shouldIgnoreCopyPaste(e.target)) {
if (!(e.target instanceof Element)) {
return
}
if (
(e.target instanceof HTMLTextAreaElement &&
e.target.type === 'textarea') ||
(e.target instanceof HTMLInputElement && e.target.type === 'text')
) {
// Default system copy
return
}
const isTargetInGraph =
e.target.classList.contains('litegraph') ||
e.target.classList.contains('graph-canvas-container') ||
e.target.id === 'graph-canvas'
// copy nodes and clear clipboard
const canvas = canvasStore.canvas
if (canvas?.selectedItems) {
const serializedData = canvas.copyToClipboard()
if (isTargetInGraph && canvas?.selectedItems) {
canvas.copyToClipboard()
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(btoa(serializedData))
)
e.clipboardData?.setData('text', ' ')
e.preventDefault()
e.stopImmediatePropagation()
return false

View File

@@ -21,10 +21,7 @@ import {
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { SUPPORT_URL } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -454,11 +451,6 @@ export function useCoreCommands(): ComfyCommand[] {
category: 'essentials' as const,
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
if (isCloud) {
useTelemetry()?.trackWorkflowExecution()
}
await app.queuePrompt(0, batchCount)
}
},
@@ -470,11 +462,6 @@ export function useCoreCommands(): ComfyCommand[] {
category: 'essentials' as const,
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
if (isCloud) {
useTelemetry()?.trackWorkflowExecution()
}
await app.queuePrompt(-1, batchCount)
}
},
@@ -776,7 +763,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
window.open(SUPPORT_URL, '_blank')
window.open('https://support.comfy.org/', '_blank')
}
},
{

View File

@@ -7,22 +7,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil'
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
function pasteClipboardItems(data: DataTransfer): boolean {
const rawData = data.getData('text/html')
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
if (!match) return false
try {
useCanvasStore()
.getCanvas()
._deserializeItems(JSON.parse(atob(match)), {})
return true
} catch (err) {
console.error(err)
}
return false
}
/**
* Adds a handler on paste that extracts and loads images or workflows from pasted JSON data
@@ -54,10 +38,15 @@ export const usePaste = () => {
}
useEventListener(document, 'paste', async (e) => {
if (shouldIgnoreCopyPaste(e.target)) {
// Default system copy
return
}
const isTargetInGraph =
e.target instanceof Element &&
(e.target.classList.contains('litegraph') ||
e.target.classList.contains('graph-canvas-container') ||
e.target.id === 'graph-canvas')
// If the target is not in the graph, we don't want to handle the paste event
if (!isTargetInGraph) return
// ctrl+shift+v is used to paste nodes with connections
// this is handled by litegraph
if (workspaceStore.shiftDown) return
@@ -120,7 +109,6 @@ export const usePaste = () => {
return
}
}
if (pasteClipboardItems(data)) return
// No image found. Look for node data
data = data.getData('text/plain')

View File

@@ -1 +0,0 @@
export const MONTHLY_SUBSCRIPTION_PRICE = 20

View File

@@ -0,0 +1,12 @@
import { t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.CloudBadge',
topbarBadges: [
{
label: t('g.beta'),
text: 'Comfy Cloud'
}
]
})

View File

@@ -1,36 +0,0 @@
import { computed } from 'vue'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
import type { TopbarBadge } from '@/types/comfy'
const badges = computed<TopbarBadge[]>(() => {
const result: TopbarBadge[] = []
// Add server health alert first (if present)
const alert = remoteConfig.value.server_health_alert
if (alert) {
result.push({
text: alert.message,
label: alert.badge,
variant: alert.severity ?? 'error',
tooltip: alert.tooltip
})
}
// Always add cloud badge last (furthest right)
result.push({
label: t('g.beta'),
text: 'Comfy Cloud'
})
return result
})
useExtensionService().registerExtension({
name: 'Comfy.Cloud.Badges',
get topbarBadges() {
return badges.value
}
})

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