Compare commits

..

110 Commits

Author SHA1 Message Date
Jin Yi
f6f4ed3b8c [refactor] node version status -> checkBannedStatus, checkPendingStatus 2025-07-30 12:56:48 +09:00
Jin Yi
b93d7aecc0 [refactor] WIP: checkNodeCompatibility refactor 2025-07-30 12:48:58 +09:00
Jin Yi
6c58d08f08 [refactor] WIP: pack card footer check conflict logic simplified 2025-07-30 12:09:18 +09:00
Jin Yi
2d9cb002f0 [refactor] WIP: python version, has_registry, security_pending deleted 2025-07-30 12:03:00 +09:00
Jin Yi
d3f0ae33be [feat] Show conflicting status for installed packages with compatibility issues (#4579)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-29 17:31:57 +09:00
Jin Yi
b7c43707d3 [debug] Conflict detection debugging (#4577) 2025-07-29 17:30:47 +09:00
Jin Yi
c1577aac48 [Design] Update InfoPanel styling and layout (#4553) 2025-07-29 14:01:05 +09:00
Jin Yi
c3b655afd6 [feat] Add bulk import failure info API and improve conflict detection (#4550)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-28 14:13:01 -07:00
Jin Yi
60ae1cf994 [Design] Update PackVersionSelectorPopover styling (#4554) 2025-07-28 14:02:24 -07:00
Jin Yi
fd66f593ad [fix] Simplify PackEnableToggle state management (#4551) 2025-07-27 19:39:03 +09:00
Jin Yi
d73c955b80 [Manager] Optimize conflict detection with bulk API and version support (#4538) 2025-07-27 08:06:09 +09:00
Jin Yi
7ca43c65b7 [Manager] Fix version selector conflict detection and UI improvements (#4539) 2025-07-26 08:44:22 +09:00
Jin Yi
0721cdbd64 [Manager] Fix banned status logic to only check Registry status (#4535) 2025-07-26 07:14:38 +09:00
Jin Yi
24ae277ebe [Manager] Fix toggle modal dismiss bug (#4534) 2025-07-26 07:14:24 +09:00
Jin Yi
63dd800a66 chore: delete unnecessary description 2025-07-25 14:15:41 +09:00
comfy-jinyi
7c2a648ab8 [Manager] Fix package version matching in conflict detection (#4530)
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-25 14:10:55 +09:00
Jin Yi
b169772f9f Manager Conflict Nofitication (#4443)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-25 08:35:46 +09:00
Jin Yi
0a2f2d8368 [Manager] Compatibility Detection Logic (#4348)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-21 17:43:43 +09:00
github-actions
5055092cc5 Update locales [skip ci] 2025-07-21 17:43:42 +09:00
github-actions
c2bd1668e1 Update locales [skip ci] 2025-07-21 17:43:42 +09:00
github-actions
7c9c8b97ae Update locales [skip ci] 2025-07-21 17:43:42 +09:00
github-actions
dc00274cd9 Update locales [skip ci] 2025-07-21 17:43:42 +09:00
Jin Yi
0c4f6205e7 [test] Update PackVersionBadge test to use role selector instead of Button component 2025-07-21 17:43:42 +09:00
github-actions
a9d08c927b Update locales [skip ci] 2025-07-21 17:43:42 +09:00
bymyself
e4e5e4d331 fix rebase error 2025-07-21 17:43:42 +09:00
Jin Yi
e31cf997c9 [Manager] “Restarting” state after clicking restart button (#4269) 2025-07-21 17:43:42 +09:00
Jin Yi
79bf5d9b67 [Manager] Add update all button functionality
- Add PackUpdateButton component for bulk updates
- Add useUpdateAvailableNodes composable to track available updates
- Integrate update all button in RegistrySearchBar
- Add localization strings for update functionality
- Add comprehensive tests for update functionality
- Add loading state to PackActionButton
2025-07-21 17:43:42 +09:00
bymyself
19f6138f92 [tests] Update useServerLogs test after log subscription change
The test was expecting subscribeLogs(false) to be called, but this was commented out in commit 33d64475 to fix logs stopping after the first of multiple queue tasks. Updated test to reflect this temporary change.
2025-07-21 17:43:42 +09:00
bymyself
f11494bc70 remove the temporary check for legacy custom node version of manager 2025-07-21 17:43:42 +09:00
bymyself
ee7d07a0cd fix: logs stops listening after 1st of multiple queue tasks 2025-07-21 17:43:42 +09:00
bymyself
b7c8180eb3 [tests] Update useServerLogs test to handle task-started events
Update test to simulate cm-task-started events before logs events to match the actual behavior of the composable.
2025-07-21 17:43:42 +09:00
Christian Byrne
645e235dd6 [Manager] Fix: failed tasks logs not correctly partitioned in UI (#4242) 2025-07-21 17:43:42 +09:00
bymyself
f8f27aaf72 fix failed task tab state binding 2025-07-21 17:43:42 +09:00
Christian Byrne
ad6395933d [Manager] Filter task queue and history by client id (#4241) 2025-07-21 17:43:42 +09:00
github-actions
388aebb37c Update locales [skip ci] 2025-07-21 17:43:42 +09:00
bymyself
db8563c5d1 fix rebase errors 2025-07-21 17:43:42 +09:00
bymyself
cc7c36620f [manager] Fix test failures and missing type definitions
- Fix ManagerProgressDialogContent test mock to include all required store methods
- Add missing MergedNodePack, RegistryPack type definitions and isMergedNodePack type guard
- Ensure all unit tests (548 passed) and component tests (174 passed) are working
- Fix TypeScript compilation errors related to manager store interfaces
2025-07-21 17:43:42 +09:00
bymyself
a28aa80370 [manager] Update tests for new manager API
Updated tests for manager queue composable, server logs composable, and manager store to work with the new API signatures and functionality.
2025-07-21 17:43:42 +09:00
bymyself
5765e0f105 [manager] Update UI components for new manager interface
Updated manager dialog components, pack cards, version selectors, and action buttons to work with the new manager API and state management structure.
2025-07-21 17:43:42 +09:00
bymyself
d04a02e3c7 [manager] Update composables and state management
Updated manager queue composable, server logs composable, workflow packs composable, and manager store to support the new manager API structure and state management patterns.
2025-07-21 17:43:42 +09:00
bymyself
b5b7945d8b [manager] Update core services for new manager API
Updated ComfyUI Manager service and dialog service to support the new menu items structure and API endpoints.
2025-07-21 17:43:42 +09:00
bymyself
e279bd4329 [manager] Update type definitions and schemas for menu items migration
Updated ComfyUI Manager types and API schemas to support the new menu items structure and manager functionality.
2025-07-21 17:43:42 +09:00
github-actions
3a6feae81b Update locales [skip ci] 2025-07-21 17:43:42 +09:00
bymyself
1b03813279 dont show missing nodes button in legacy manager mode 2025-07-21 17:43:42 +09:00
bymyself
7e1095dc89 use correct response shape 2025-07-21 17:43:42 +09:00
github-actions
db71360be4 Update locales [skip ci] 2025-07-21 17:43:42 +09:00
bymyself
7fe48ba5dc add "Check for Updates", "Install Missing" menu items 2025-07-21 17:43:42 +09:00
bymyself
b200115b5b move legacy option to startup arg 2025-07-21 17:43:42 +09:00
bymyself
fc9964031b await promises. update settings schema 2025-07-21 17:43:42 +09:00
github-actions
9db37b7f56 Update locales [skip ci] 2025-07-21 17:43:42 +09:00
bymyself
4256efd028 migrate manager menu items 2025-07-21 17:43:42 +09:00
github-actions
df67cdd86c Update locales [skip ci] 2025-07-21 17:41:45 +09:00
bymyself
478499c188 [Update to v2 API] update WS done message 2025-07-21 17:41:45 +09:00
bymyself
a5efa8580e dont show missing nodes button in legacy manager mode 2025-07-21 17:41:44 +09:00
bymyself
ab2e70b4b8 improve command names 2025-07-21 17:41:44 +09:00
bymyself
27579400bf use correct response shape 2025-07-21 17:41:44 +09:00
github-actions
b9341ad144 Update locales [skip ci] 2025-07-21 17:41:44 +09:00
bymyself
9c68491129 add "Check for Updates", "Install Missing" menu items 2025-07-21 17:41:44 +09:00
github-actions
4f5ec0447c Update locales [skip ci] 2025-07-21 17:41:44 +09:00
bymyself
0d1e5cb02a Add banner indicating how to use legacy manager UI 2025-07-21 17:41:44 +09:00
bymyself
797a612227 move legacy option to startup arg 2025-07-21 17:41:44 +09:00
bymyself
0a0a2e74d5 await promises. update settings schema 2025-07-21 17:41:44 +09:00
bymyself
6824e48efe re-arrange menu items 2025-07-21 17:41:44 +09:00
bymyself
257f618ee1 switch to v2 manager API endpoints 2025-07-21 17:41:44 +09:00
github-actions
a446483d7e Update locales [skip ci] 2025-07-21 17:41:44 +09:00
bymyself
a3d7c59b6f migrate manager menu items 2025-07-21 17:41:44 +09:00
Christian Byrne
23b2302ce3 [chore] Update litegraph to 0.16.13 (#4490) 2025-07-21 00:30:09 -07:00
Comfy Org PR Bot
d833ab65a6 [chore] Update litegraph to 0.16.11 (#4484)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-20 14:20:50 +00:00
filtered
dea4a76ceb [Test] Add explicit timeout to consistently failing test (#4485) 2025-07-21 00:19:22 +10:00
filtered
db70bd61d5 [CI] Fix PR check workflow (#4482) 2025-07-20 18:45:11 +10:00
Terry Jia
ed1d944e0e [3d] remove unnecessary uploadTexture (#4357) 2025-07-19 11:38:49 -07:00
Comfy Org PR Bot
282f9ce27a [chore] Update Comfy Registry API types from comfy-api@9ccb96a (#4470)
Co-authored-by: viva-jinyi <53567196+viva-jinyi@users.noreply.github.com>
2025-07-18 15:53:21 +09:00
Rizumu Ayaka
11eff4981f Fix Help Center changelog toast overflows viewport (#4469) 2025-07-17 17:13:01 -07:00
Benjamin Lu
927773f553 Fix Danger.js Security Issues (#4462) 2025-07-16 12:15:05 -07:00
guill
1200c07fcd Add support for Feature Flags (#4439) 2025-07-15 15:59:11 -07:00
Christian Byrne
be7edab141 [feat] Add GitHub Action for automated Claude PR reviews (#4452) 2025-07-14 16:44:51 -07:00
filtered
f3168aac89 Revert "[fix] Fix Danger CI permissions for PRs from forks" (#4450) 2025-07-15 04:52:01 +10:00
Benjamin Lu
2f3c762e85 [fix] Fix Danger CI permissions for PRs from forks (#4449)
It's good to have working and in ASAP, although better approaches are being researched and investigated
2025-07-14 14:08:44 -04:00
Benjamin Lu
8b8caa4b29 Add Danger PR Review (#4442) 2025-07-13 21:21:29 -07:00
Christian Byrne
a70d69cbd2 [fix] Sync subgraph node title changes with breadcrumb navigation (#4394) 2025-07-13 07:37:48 +10:00
filtered
01c735d943 Fix cannot check widget value if undefined (#4433) 2025-07-12 09:05:58 +10:00
Comfy Org PR Bot
f0bc4c6959 [chore] Update litegraph to 0.16.9 (#4432)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-12 09:01:24 +10:00
filtered
19eaf6ecdc Fix SubgraphNode widget values ignored (#4429) 2025-07-11 22:37:23 +10:00
Comfy Org PR Bot
054077c445 [chore] Update litegraph to 0.16.8 (#4427)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-11 22:28:02 +10:00
filtered
ef9b625208 Fix DTO return type to allow clean test/merge (#4426) 2025-07-11 19:27:31 +10:00
Comfy Org PR Bot
688193ad9a 1.24.1 (#4425)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-11 18:13:36 +10:00
filtered
5c119fcbda Improve execution logic / Fix group node execution (#4422) 2025-07-11 00:40:48 -07:00
Comfy Org PR Bot
998abbbdbd [chore] Update litegraph to 0.16.7 (#4424)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-07-11 00:22:05 -07:00
filtered
696c8720b6 [CI] Improve claude code release command (#4413) 2025-07-11 00:19:59 -07:00
ComfyUI Wiki
80e5cf1b9d Update template translation (#4396)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-10 23:10:42 -07:00
Deep Roy
7cf5d1e86b Add prompt ID to interrupt API call (#4393) 2025-07-10 17:24:21 -07:00
Emanuel F.
ab43b5e421 Menu bar mobile behavior change (#4312) 2025-07-10 15:08:51 -07:00
ComfyUI Wiki
43f73f8856 Correct the translation for 'Credits' in the zh-TW locale. (#4415) 2025-07-10 12:44:25 +00:00
filtered
0b5ade3a3b [chore] Update docs - node.js versions (#4414) 2025-07-10 05:34:28 -07:00
Christian Byrne
eb63b5c536 [feat] Add Traditional Chinese language support (#4410)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-10 01:52:47 -07:00
Christian Byrne
e6d78ab22c docs: Add Claude Code command to create release (#4408) 2025-07-09 22:53:07 -07:00
Christian Byrne
bc4753e119 [docs] Improve language contribution process (#4409) 2025-07-09 22:51:12 -07:00
Comfy Org PR Bot
733c9f81b0 1.24.0 (#4406)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-07-09 17:20:33 -07:00
filtered
1afae4f723 [CI] Fix frontend package release skipped (#4404) 2025-07-09 16:28:55 -07:00
bmcomfy
1632798fd2 [System Pop Up] Improve help center and what's new popup UI (#4395)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-09 16:17:50 -07:00
Christian Byrne
103139fdab 1.24.0 (#4401)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-09 15:00:20 -07:00
filtered
834ac3ea61 [CodeHealth] Simplify code as follow-up to #4354 (#4400) 2025-07-09 13:08:33 -07:00
Christian Byrne
22c70d5d1b [fix] use getter functions for sidebar tab command labels to resolve i18n collection issues (#4370)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-08 23:50:49 -07:00
bmcomfy
f5b03f323d [System Pop Up] Hide hidden and deprecated settings from search results (#4390) 2025-07-08 17:19:37 -07:00
bmcomfy
d6f6407c44 [System Pop Up] Add setting to disable version update notifications (#4388) 2025-07-08 14:43:11 -07:00
Christian Byrne
2906ea3fd9 [fix] Correct API node pricing discrepancies (#4381) 2025-07-07 23:33:55 -07:00
Comfy Org PR Bot
c03771988d [chore] Update litegraph to 0.16.6 (#4380)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-07-07 23:16:09 -07:00
Christian Byrne
368c54bcf6 [docs] Add Claude Code command for automated hotfix releases (#4369) 2025-07-07 20:43:50 -07:00
Christian Byrne
f1575a693f [update] Video to Video API node pricing (#4378) 2025-07-07 20:24:33 -07:00
Christian Byrne
4eeff5533a [feat] Add dynamic pricing for new API nodes (#4367) 2025-07-06 18:31:04 -07:00
178 changed files with 28732 additions and 5631 deletions

View File

@@ -0,0 +1,708 @@
# Comprehensive PR Review for ComfyUI Frontend
<task>
You are performing a comprehensive code review for PR #$1 in the ComfyUI frontend repository. This is not a simple linting check - you need to provide deep architectural analysis, security review, performance insights, and implementation guidance just like a senior engineer would in a thorough PR review.
Your review should cover:
1. Architecture and design patterns
2. Security vulnerabilities and risks
3. Performance implications
4. Code quality and maintainability
5. Integration with existing systems
6. Best practices and conventions
7. Testing considerations
8. Documentation needs
</task>
Arguments: PR number passed via PR_NUMBER environment variable
## Phase 0: Initialize Variables and Helper Functions
```bash
# Validate PR_NUMBER first thing
if [ -z "$PR_NUMBER" ]; then
echo "Error: PR_NUMBER environment variable is not set"
echo "Usage: PR_NUMBER=<number> claude run /comprehensive-pr-review"
exit 1
fi
# Initialize all counters at the start
CRITICAL_COUNT=0
HIGH_COUNT=0
MEDIUM_COUNT=0
LOW_COUNT=0
ARCHITECTURE_ISSUES=0
SECURITY_ISSUES=0
PERFORMANCE_ISSUES=0
QUALITY_ISSUES=0
# Helper function for posting review comments
post_review_comment() {
local file_path=$1
local line_number=$2
local severity=$3 # critical/high/medium/low
local category=$4 # architecture/security/performance/quality
local issue=$5
local context=$6
local suggestion=$7
# Update counters
case $severity in
"critical") ((CRITICAL_COUNT++)) ;;
"high") ((HIGH_COUNT++)) ;;
"medium") ((MEDIUM_COUNT++)) ;;
"low") ((LOW_COUNT++)) ;;
esac
case $category in
"architecture") ((ARCHITECTURE_ISSUES++)) ;;
"security") ((SECURITY_ISSUES++)) ;;
"performance") ((PERFORMANCE_ISSUES++)) ;;
"quality") ((QUALITY_ISSUES++)) ;;
esac
# Post inline comment via GitHub CLI
local comment="${issue}\n${context}\n${suggestion}"
gh pr review $PR_NUMBER --comment --body "$comment" -F - <<< "$comment"
}
```
## Phase 1: Environment Setup and PR Context
```bash
# Pre-flight checks
check_prerequisites() {
# Check gh CLI is available
if ! command -v gh &> /dev/null; then
echo "Error: gh CLI is not installed"
exit 1
fi
# In GitHub Actions, auth is handled via GITHUB_TOKEN
if [ -n "$GITHUB_ACTIONS" ] && [ -z "$GITHUB_TOKEN" ]; then
echo "Error: GITHUB_TOKEN is not set in GitHub Actions"
exit 1
fi
# Check if we're authenticated
if ! gh auth status &> /dev/null; then
echo "Error: Not authenticated with GitHub. Run 'gh auth login'"
exit 1
fi
# Set repository if not already set
if [ -z "$REPOSITORY" ]; then
REPOSITORY="Comfy-Org/ComfyUI_frontend"
fi
# Check PR exists and is open
PR_STATE=$(gh pr view $PR_NUMBER --repo $REPOSITORY --json state -q .state 2>/dev/null || echo "NOT_FOUND")
if [ "$PR_STATE" = "NOT_FOUND" ]; then
echo "Error: PR #$PR_NUMBER not found in $REPOSITORY"
exit 1
elif [ "$PR_STATE" != "OPEN" ]; then
echo "Error: PR #$PR_NUMBER is not open (state: $PR_STATE)"
exit 1
fi
# Check API rate limits
RATE_REMAINING=$(gh api /rate_limit --jq '.rate.remaining' 2>/dev/null || echo "5000")
if [ "$RATE_REMAINING" -lt 100 ]; then
echo "Warning: Low API rate limit: $RATE_REMAINING remaining"
if [ "$RATE_REMAINING" -lt 50 ]; then
echo "Error: Insufficient API rate limit for comprehensive review"
exit 1
fi
fi
echo "Pre-flight checks passed"
}
# Run pre-flight checks
check_prerequisites
echo "Starting comprehensive review of PR #$PR_NUMBER"
# Fetch PR information with error handling
echo "Fetching PR information..."
if ! gh pr view $PR_NUMBER --repo $REPOSITORY --json files,title,body,additions,deletions,baseRefName,headRefName > pr_info.json; then
echo "Error: Failed to fetch PR information"
exit 1
fi
# Extract branch names
BASE_BRANCH=$(jq -r '.baseRefName' < pr_info.json)
HEAD_BRANCH=$(jq -r '.headRefName' < pr_info.json)
# Checkout PR branch locally for better file inspection
echo "Checking out PR branch..."
git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER"
git checkout "pr-$PR_NUMBER"
# Get changed files using git locally (much faster)
git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt
# Get the diff using git locally
git diff "origin/$BASE_BRANCH" > pr_diff.txt
# Get detailed file changes with line numbers
git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt
# For API compatibility, create a simplified pr_files.json
echo '[]' > pr_files.json
while IFS=$'\t' read -r status file; do
if [[ "$status" != "D" ]]; then # Skip deleted files
# Get the patch for this file
patch=$(git diff "origin/$BASE_BRANCH" -- "$file" | jq -Rs .)
additions=$(git diff --numstat "origin/$BASE_BRANCH" -- "$file" | awk '{print $1}')
deletions=$(git diff --numstat "origin/$BASE_BRANCH" -- "$file" | awk '{print $2}')
jq --arg file "$file" \
--arg patch "$patch" \
--arg additions "$additions" \
--arg deletions "$deletions" \
'. += [{
"filename": $file,
"patch": $patch,
"additions": ($additions | tonumber),
"deletions": ($deletions | tonumber)
}]' pr_files.json > pr_files.json.tmp
mv pr_files.json.tmp pr_files.json
fi
done < file_changes.txt
# Setup caching directory
CACHE_DIR=".claude-review-cache"
mkdir -p "$CACHE_DIR"
# Function to cache analysis results
cache_analysis() {
local file_path=$1
local analysis_result=$2
local file_hash=$(git hash-object "$file_path" 2>/dev/null || echo "no-hash")
if [ "$file_hash" != "no-hash" ]; then
echo "$analysis_result" > "$CACHE_DIR/${file_hash}.cache"
fi
}
# Function to get cached analysis
get_cached_analysis() {
local file_path=$1
local file_hash=$(git hash-object "$file_path" 2>/dev/null || echo "no-hash")
if [ "$file_hash" != "no-hash" ] && [ -f "$CACHE_DIR/${file_hash}.cache" ]; then
cat "$CACHE_DIR/${file_hash}.cache"
return 0
fi
return 1
}
# Clean old cache entries (older than 7 days)
find "$CACHE_DIR" -name "*.cache" -mtime +7 -delete 2>/dev/null || true
```
## Phase 2: Load Comprehensive Knowledge Base
```bash
# Don't create knowledge directory until we know we need it
KNOWLEDGE_FOUND=false
# Use local cache for knowledge base to avoid repeated downloads
KNOWLEDGE_CACHE_DIR=".claude-knowledge-cache"
mkdir -p "$KNOWLEDGE_CACHE_DIR"
# Option to use cloned prompt library for better performance
PROMPT_LIBRARY_PATH="../comfy-claude-prompt-library"
if [ -d "$PROMPT_LIBRARY_PATH" ]; then
echo "Using local prompt library at $PROMPT_LIBRARY_PATH"
USE_LOCAL_PROMPT_LIBRARY=true
else
echo "No local prompt library found, will use GitHub API"
USE_LOCAL_PROMPT_LIBRARY=false
fi
# Function to fetch with cache
fetch_with_cache() {
local url=$1
local output_file=$2
local cache_file="$KNOWLEDGE_CACHE_DIR/$(echo "$url" | sed 's/[^a-zA-Z0-9]/_/g')"
# Check if cached version exists and is less than 1 day old
if [ -f "$cache_file" ] && [ $(find "$cache_file" -mtime -1 2>/dev/null | wc -l) -gt 0 ]; then
# Create knowledge directory only when we actually have content
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp "$cache_file" "$output_file"
echo "Using cached version of $(basename "$output_file")"
return 0
fi
# Try to fetch fresh version
if curl -s -f "$url" > "$output_file.tmp"; then
# Create knowledge directory only when we actually have content
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
mv "$output_file.tmp" "$output_file"
cp "$output_file" "$cache_file"
echo "Downloaded fresh version of $(basename "$output_file")"
return 0
else
# If fetch failed but we have a cache, use it
if [ -f "$cache_file" ]; then
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp "$cache_file" "$output_file"
echo "Using stale cache for $(basename "$output_file") (download failed)"
return 0
fi
echo "Failed to load $(basename "$output_file")"
return 1
fi
}
# Load REPOSITORY_GUIDE.md for deep architectural understanding
echo "Loading ComfyUI Frontend repository guide..."
if [ "$USE_LOCAL_PROMPT_LIBRARY" = "true" ] && [ -f "$PROMPT_LIBRARY_PATH/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md" ]; then
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp "$PROMPT_LIBRARY_PATH/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md" "review_knowledge/repository_guide.md"
echo "Loaded repository guide from local prompt library"
else
fetch_with_cache "https://raw.githubusercontent.com/Comfy-Org/comfy-claude-prompt-library/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md" "review_knowledge/repository_guide.md"
fi
# 3. Discover and load relevant knowledge folders from GitHub API
echo "Discovering available knowledge folders..."
KNOWLEDGE_API_URL="https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/knowledge"
if KNOWLEDGE_FOLDERS=$(curl -s "$KNOWLEDGE_API_URL" | jq -r '.[] | select(.type=="dir") | .name' 2>/dev/null); then
echo "Available knowledge folders: $KNOWLEDGE_FOLDERS"
# Analyze changed files to determine which knowledge folders might be relevant
CHANGED_FILES=$(cat changed_files.txt)
PR_TITLE=$(jq -r '.title' < pr_info.json)
PR_BODY=$(jq -r '.body // ""' < pr_info.json)
# For each knowledge folder, check if it might be relevant to the PR
for folder in $KNOWLEDGE_FOLDERS; do
# Simple heuristic: if folder name appears in changed file paths or PR context
if echo "$CHANGED_FILES $PR_TITLE $PR_BODY" | grep -qi "$folder"; then
echo "Loading knowledge folder: $folder"
# Fetch all files in that knowledge folder
FOLDER_API_URL="https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/knowledge/$folder"
curl -s "$FOLDER_API_URL" | jq -r '.[] | select(.type=="file") | .download_url' 2>/dev/null | \
while read url; do
if [ -n "$url" ]; then
filename=$(basename "$url")
fetch_with_cache "$url" "review_knowledge/${folder}_${filename}"
fi
done
fi
done
else
echo "Could not discover knowledge folders"
fi
# 4. Load validation rules from the repository
echo "Loading validation rules..."
VALIDATION_API_URL="https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/commands/validation"
if VALIDATION_FILES=$(curl -s "$VALIDATION_API_URL" | jq -r '.[] | select(.name | contains("frontend") or contains("security") or contains("performance")) | .download_url' 2>/dev/null); then
for url in $VALIDATION_FILES; do
if [ -n "$url" ]; then
filename=$(basename "$url")
fetch_with_cache "$url" "review_knowledge/validation_${filename}"
fi
done
else
echo "Could not load validation rules"
fi
# 5. Load local project guidelines
if [ -f "CLAUDE.md" ]; then
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp CLAUDE.md review_knowledge/local_claude.md
fi
if [ -f ".github/CLAUDE.md" ]; then
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
mkdir -p review_knowledge
KNOWLEDGE_FOUND=true
fi
cp .github/CLAUDE.md review_knowledge/github_claude.md
fi
```
## Phase 3: Deep Analysis Instructions
Perform a comprehensive analysis covering these areas:
### 3.1 Architectural Analysis
Based on the repository guide and project summary, evaluate:
- Does this change align with the established architecture patterns?
- Are domain boundaries respected?
- Is the extension system used appropriately?
- Are components properly organized by feature?
- Does it follow the established service/composable/store patterns?
### 3.2 Code Quality Beyond Linting
- Cyclomatic complexity and cognitive load
- SOLID principles adherence
- DRY violations that aren't caught by simple duplication checks
- Proper abstraction levels
- Interface design and API clarity
- No leftover debug code (console.log, commented code, TODO comments)
### 3.3 Library Usage Enforcement
CRITICAL: Never re-implement functionality that exists in our standard libraries:
- **Tailwind CSS**: Use utility classes instead of custom CSS or style attributes
- **PrimeVue**: Never re-implement components that exist in PrimeVue (buttons, modals, dropdowns, etc.)
- **VueUse**: Never re-implement composables that exist in VueUse (useLocalStorage, useDebounceFn, etc.)
- **Lodash**: Never re-implement utility functions (debounce, throttle, cloneDeep, etc.)
- **Common components**: Reuse components from src/components/common/
- **DOMPurify**: Always use for HTML sanitization
- **Fuse.js**: Use for fuzzy search functionality
- **Marked**: Use for markdown parsing
- **Pinia**: Use for global state management, not custom solutions
- **Zod**: Use for form validation with zodResolver pattern
- **Tiptap**: Use for rich text/markdown editing
- **Xterm.js**: Use for terminal emulation
- **Axios**: Use for HTTP client initialization
### 3.4 Security Deep Dive
Beyond obvious vulnerabilities:
- Authentication/authorization implications
- Data validation completeness
- State management security
- Cross-origin concerns
- Extension security boundaries
### 3.5 Performance Analysis
- Render performance implications
- Layout thrashing prevention
- Memory leak potential
- Network request optimization
- State management efficiency
### 3.6 Integration Concerns
- Breaking changes to internal APIs
- Extension compatibility
- Backward compatibility
- Migration requirements
## Phase 4: Create Detailed Review Comments
CRITICAL: Keep comments extremely concise and effective. Use only as many words as absolutely necessary.
- NO markdown formatting (no #, ##, ###, **, etc.)
- NO emojis
- Get to the point immediately
- Burden the reader as little as possible
For each issue found, create a concise inline comment with:
1. What's wrong (one line)
2. Why it matters (one line)
3. How to fix it (one line)
4. Code example only if essential
```bash
# Helper function for comprehensive comments
post_review_comment() {
local file_path=$1
local line_number=$2
local severity=$3 # critical/high/medium/low
local category=$4 # architecture/security/performance/quality
local issue=$5
local context=$6
local suggestion=$7
local example=$8
local body="### [$category] $severity Priority
**Issue**: $issue
**Context**: $context
**Suggestion**: $suggestion"
if [ -n "$example" ]; then
body="$body
**Example**:
\`\`\`typescript
$example
\`\`\`"
fi
body="$body
*Related: See [repository guide](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md) for patterns*"
gh api -X POST /repos/$REPOSITORY/pulls/$PR_NUMBER/comments \
-f path="$file_path" \
-f line=$line_number \
-f body="$body" \
-f commit_id="$COMMIT_SHA" \
-f side='RIGHT' || echo "Failed to post comment at $file_path:$line_number"
}
```
## Phase 5: Validation Rules Application
Apply ALL validation rules from the loaded knowledge, but focus on the changed lines:
### From Frontend Standards
- Vue 3 Composition API patterns
- Component communication patterns
- Proper use of composables
- TypeScript strict mode compliance
- Bundle optimization
### From Security Audit
- Input validation
- XSS prevention
- CSRF protection
- Secure state management
- API security
### From Performance Check
- Render optimization
- Memory management
- Network efficiency
- Bundle size impact
## Phase 6: Contextual Review Based on PR Type
Analyze the PR description and changes to determine the type:
```bash
# Extract PR metadata with error handling
if [ ! -f pr_info.json ]; then
echo "Error: pr_info.json not found"
exit 1
fi
PR_TITLE=$(jq -r '.title // "Unknown"' < pr_info.json)
PR_BODY=$(jq -r '.body // ""' < pr_info.json)
FILE_COUNT=$(wc -l < changed_files.txt)
ADDITIONS=$(jq -r '.additions // 0' < pr_info.json)
DELETIONS=$(jq -r '.deletions // 0' < pr_info.json)
# Determine PR type and apply specific review criteria
if echo "$PR_TITLE $PR_BODY" | grep -qiE "(feature|feat)"; then
echo "Detected feature PR - applying feature review criteria"
# Check for tests, documentation, backward compatibility
elif echo "$PR_TITLE $PR_BODY" | grep -qiE "(fix|bug)"; then
echo "Detected bug fix - checking root cause and regression tests"
# Verify fix addresses root cause, includes tests
elif echo "$PR_TITLE $PR_BODY" | grep -qiE "(refactor)"; then
echo "Detected refactoring - ensuring behavior preservation"
# Check that tests still pass, no behavior changes
fi
```
## Phase 7: Generate Comprehensive Summary
After all inline comments, create a detailed summary:
```bash
# Initialize metrics tracking
REVIEW_START_TIME=$(date +%s)
# Create the comprehensive summary
gh pr review $PR_NUMBER --comment --body "# Comprehensive PR Review
This review is generated by Claude. It may not always be accurate, as with human reviewers. If you believe that any of the comments are invalid or incorrect, please state why for each. For others, please implement the changes in one way or another.
## Review Summary
**PR**: $PR_TITLE (#$PR_NUMBER)
**Impact**: $ADDITIONS additions, $DELETIONS deletions across $FILE_COUNT files
### Issue Distribution
- Critical: $CRITICAL_COUNT
- High: $HIGH_COUNT
- Medium: $MEDIUM_COUNT
- Low: $LOW_COUNT
### Category Breakdown
- Architecture: $ARCHITECTURE_ISSUES issues
- Security: $SECURITY_ISSUES issues
- Performance: $PERFORMANCE_ISSUES issues
- Code Quality: $QUALITY_ISSUES issues
## Key Findings
### Architecture & Design
[Detailed architectural analysis based on repository patterns]
### Security Considerations
[Security implications beyond basic vulnerabilities]
### Performance Impact
[Performance analysis including bundle size, render impact]
### Integration Points
[How this affects other systems, extensions, etc.]
## Positive Observations
[What was done well, good patterns followed]
## References
- [Repository Architecture Guide](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md)
- [Frontend Standards](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/.claude/commands/validation/frontend-code-standards.md)
- [Security Guidelines](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/.claude/commands/validation/security-audit.md)
## Next Steps
1. Address critical issues before merge
2. Consider architectural feedback for long-term maintainability
3. Add tests for uncovered scenarios
4. Update documentation if needed
---
*This is a comprehensive automated review. For architectural decisions requiring human judgment, please request additional manual review.*"
```
## Important: Think Deeply
When reviewing:
1. **Think hard** about architectural implications
2. Consider how changes affect the entire system
3. Look for subtle bugs and edge cases
4. Evaluate maintainability over time
5. Consider extension developer experience
6. Think about migration paths
This is a COMPREHENSIVE review, not a linting pass. Provide the same quality feedback a senior engineer would give after careful consideration.
## Phase 8: Track Review Metrics
After completing the review, save metrics for analysis:
```bash
# Calculate review duration
REVIEW_END_TIME=$(date +%s)
REVIEW_DURATION=$((REVIEW_END_TIME - REVIEW_START_TIME))
# Calculate total issues
TOTAL_ISSUES=$((CRITICAL_COUNT + HIGH_COUNT + MEDIUM_COUNT + LOW_COUNT))
# Create metrics directory if it doesn't exist
METRICS_DIR=".claude/review-metrics"
mkdir -p "$METRICS_DIR"
# Generate metrics file
METRICS_FILE="$METRICS_DIR/metrics-$(date +%Y%m).json"
# Create or update monthly metrics file
if [ -f "$METRICS_FILE" ]; then
# Append to existing file
jq -n \
--arg pr "$PR_NUMBER" \
--arg title "$PR_TITLE" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg duration "$REVIEW_DURATION" \
--arg files "$FILE_COUNT" \
--arg additions "$ADDITIONS" \
--arg deletions "$DELETIONS" \
--arg total "$TOTAL_ISSUES" \
--arg critical "$CRITICAL_COUNT" \
--arg high "$HIGH_COUNT" \
--arg medium "$MEDIUM_COUNT" \
--arg low "$LOW_COUNT" \
--arg architecture "$ARCHITECTURE_ISSUES" \
--arg security "$SECURITY_ISSUES" \
--arg performance "$PERFORMANCE_ISSUES" \
--arg quality "$QUALITY_ISSUES" \
'{
pr_number: $pr,
pr_title: $title,
timestamp: $timestamp,
review_duration_seconds: ($duration | tonumber),
files_reviewed: ($files | tonumber),
lines_added: ($additions | tonumber),
lines_deleted: ($deletions | tonumber),
issues: {
total: ($total | tonumber),
by_severity: {
critical: ($critical | tonumber),
high: ($high | tonumber),
medium: ($medium | tonumber),
low: ($low | tonumber)
},
by_category: {
architecture: ($architecture | tonumber),
security: ($security | tonumber),
performance: ($performance | tonumber),
quality: ($quality | tonumber)
}
}
}' > "$METRICS_FILE.new"
# Merge with existing data
jq -s '.[0] + [.[1]]' "$METRICS_FILE" "$METRICS_FILE.new" > "$METRICS_FILE.tmp"
mv "$METRICS_FILE.tmp" "$METRICS_FILE"
rm "$METRICS_FILE.new"
else
# Create new file
jq -n \
--arg pr "$PR_NUMBER" \
--arg title "$PR_TITLE" \
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg duration "$REVIEW_DURATION" \
--arg files "$FILE_COUNT" \
--arg additions "$ADDITIONS" \
--arg deletions "$DELETIONS" \
--arg total "$TOTAL_ISSUES" \
--arg critical "$CRITICAL_COUNT" \
--arg high "$HIGH_COUNT" \
--arg medium "$MEDIUM_COUNT" \
--arg low "$LOW_COUNT" \
--arg architecture "$ARCHITECTURE_ISSUES" \
--arg security "$SECURITY_ISSUES" \
--arg performance "$PERFORMANCE_ISSUES" \
--arg quality "$QUALITY_ISSUES" \
'[{
pr_number: $pr,
pr_title: $title,
timestamp: $timestamp,
review_duration_seconds: ($duration | tonumber),
files_reviewed: ($files | tonumber),
lines_added: ($additions | tonumber),
lines_deleted: ($deletions | tonumber),
issues: {
total: ($total | tonumber),
by_severity: {
critical: ($critical | tonumber),
high: ($high | tonumber),
medium: ($medium | tonumber),
low: ($low | tonumber)
},
by_category: {
architecture: ($architecture | tonumber),
security: ($security | tonumber),
performance: ($performance | tonumber),
quality: ($quality | tonumber)
}
}
}]' > "$METRICS_FILE"
fi
echo "Review metrics saved to $METRICS_FILE"
```
This creates monthly metrics files (e.g., `metrics-202407.json`) that track:
- Which PRs were reviewed
- How long reviews took
- Types and severity of issues found
- Trends over time
You can later analyze these to see patterns and improve your development process.

View File

@@ -0,0 +1,625 @@
# Create Frontend Release
This command guides you through creating a comprehensive frontend release with semantic versioning analysis, automated change detection, security scanning, and multi-stage human verification.
<task>
Create a frontend release with version type: $ARGUMENTS
Expected format: Version increment type and optional description
Examples:
- `patch` - Bug fixes only
- `minor` - New features, backward compatible
- `major` - Breaking changes
- `prerelease` - Alpha/beta/rc releases
- `patch "Critical security fixes"` - With custom description
- `minor --skip-changelog` - Skip automated changelog generation
- `minor --dry-run` - Simulate release without executing
If no arguments provided, the command will always perform prerelease if the current version is prerelease, or patch in other cases. This command will never perform minor or major releases without explicit direction.
</task>
## Prerequisites
Before starting, ensure:
- You have push access to the repository
- GitHub CLI (`gh`) is authenticated
- You're on a clean main branch working tree
- All intended changes are merged to main
- You understand the scope of changes being released
## Critical Checks Before Starting
### 1. Check Current Version Status
```bash
# Get current version and check if it's a pre-release
CURRENT_VERSION=$(node -p "require('./package.json').version")
if [[ "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then
echo "⚠️ Current version $CURRENT_VERSION is a pre-release"
echo "Consider releasing stable (e.g., 1.24.0-1 → 1.24.0) first"
fi
```
### 2. Find Last Stable Release
```bash
# Get last stable release tag (no pre-release suffix)
LAST_STABLE=$(git tag -l "v*" | grep -v "\-" | sort -V | tail -1)
echo "Last stable release: $LAST_STABLE"
```
## Configuration Options
**Environment Variables:**
- `RELEASE_SKIP_SECURITY_SCAN=true` - Skip security audit
- `RELEASE_AUTO_APPROVE=true` - Skip some confirmation prompts
- `RELEASE_DRY_RUN=true` - Simulate release without executing
## Release Process
### Step 1: Environment Safety Check
1. Verify clean working directory:
```bash
git status --porcelain
```
2. Confirm on main branch:
```bash
git branch --show-current
```
3. Pull latest changes:
```bash
git pull origin main
```
4. Check GitHub CLI authentication:
```bash
gh auth status
```
5. Verify npm/PyPI publishing access (dry run)
6. **CONFIRMATION REQUIRED**: Environment ready for release?
### Step 2: Analyze Recent Changes
1. Get current version from package.json
2. **IMPORTANT**: Determine correct base for comparison:
```bash
# If current version is pre-release, use last stable release
if [[ "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then
BASE_TAG=$LAST_STABLE
else
BASE_TAG=$(git describe --tags --abbrev=0)
fi
```
3. Find commits since base release (CRITICAL: use --first-parent):
```bash
git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent
```
4. Count total commits:
```bash
COMMIT_COUNT=$(git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent | wc -l)
echo "Found $COMMIT_COUNT commits since $BASE_TAG"
```
5. Analyze commits for:
- Breaking changes (BREAKING CHANGE, !, feat())
- New features (feat:, feature:)
- Bug fixes (fix:, bugfix:)
- Documentation changes (docs:)
- Dependency updates
6. **VERIFY PR TARGET BRANCHES**:
```bash
# Get merged PRs and verify they were merged to main
gh pr list --state merged --limit 50 --json number,title,baseRefName,mergedAt | \
jq -r '.[] | select(.baseRefName == "main") | "\(.number): \(.title)"'
```
7. **HUMAN ANALYSIS**: Review change summary and verify scope
### Step 3: Version Preview
**Version Preview:**
- Current: `${CURRENT_VERSION}`
- Proposed: Show exact version number
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
### Step 4: Security and Dependency Audit
1. Run security audit:
```bash
npm audit --audit-level moderate
```
2. Check for known vulnerabilities in dependencies
3. Scan for hardcoded secrets or credentials:
```bash
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
```
4. Verify no sensitive data in recent commits
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
### Step 5: Pre-Release Testing
1. Run complete test suite:
```bash
npm run test:unit
npm run test:component
npm run test:browser
```
2. Run type checking:
```bash
npm run typecheck
```
3. Run linting (may have issues with missing packages):
```bash
npm run lint || echo "Lint issues - verify if critical"
```
4. Test build process:
```bash
npm run build
npm run build:types
```
5. **QUALITY GATE**: All tests and builds passing?
### Step 6: Breaking Change Analysis
1. Analyze API changes in:
- Public TypeScript interfaces
- Extension APIs
- Component props
- CLAUDE.md guidelines
2. Check for:
- Removed public functions/classes
- Changed function signatures
- Deprecated feature removals
- Configuration changes
3. Generate breaking change summary
4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified?
### Step 7: Generate and Save Changelog
1. Extract commit messages since base release:
```bash
git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent > commits.txt
```
2. **CRITICAL**: Verify PR inclusion by checking merge location:
```bash
# For each significant PR mentioned, verify it's on main
for PR in ${SIGNIFICANT_PRS}; do
COMMIT=$(gh pr view $PR --json mergeCommit -q .mergeCommit.oid)
git branch -r --contains $COMMIT | grep -q "origin/main" || \
echo "WARNING: PR #$PR not on main branch!"
done
```
3. Group by type:
- 🚀 **Features** (feat:)
- 🐛 **Bug Fixes** (fix:)
- 💥 **Breaking Changes** (BREAKING CHANGE)
- 📚 **Documentation** (docs:)
- 🔧 **Maintenance** (chore:, refactor:)
- ⬆️ **Dependencies** (deps:, dependency updates)
4. Include PR numbers and links
5. Add issue references (Fixes #123)
6. **Save changelog locally:**
```bash
# Save to dated file for history
echo "$CHANGELOG" > release-notes-${NEW_VERSION}-$(date +%Y%m%d).md
# Save to current for easy access
echo "$CHANGELOG" > CURRENT_RELEASE_NOTES.md
```
7. **CHANGELOG REVIEW**: Verify all PRs listed are actually on main branch
### Step 8: Create Enhanced Release Notes
1. Create comprehensive user-facing release notes including:
- **What's New**: Major features and improvements
- **Bug Fixes**: User-visible fixes
- **Breaking Changes**: Migration guide if applicable
- **Dependencies**: Major dependency updates
- **Performance**: Notable performance improvements
- **Contributors**: Thank contributors for their work
2. Reference related documentation updates
3. Include screenshots for UI changes (if available)
4. **Save release notes:**
```bash
# Enhanced release notes for GitHub
echo "$RELEASE_NOTES" > github-release-notes-${NEW_VERSION}.md
```
5. **CONTENT REVIEW**: Release notes clear and helpful for users?
### Step 9: Create Version Bump PR
**For standard version bumps (patch/minor/major):**
```bash
# Trigger the workflow
gh workflow run version-bump.yaml -f version_type=${VERSION_TYPE}
# Workflow runs quickly - usually creates PR within 30 seconds
echo "Workflow triggered. Waiting for PR creation..."
```
**For releasing a stable version:**
1. Must manually create branch and update version:
```bash
git checkout -b version-bump-${NEW_VERSION}
# Edit package.json to remove pre-release suffix
git add package.json
git commit -m "${NEW_VERSION}"
git push origin version-bump-${NEW_VERSION}
```
2. Wait for PR creation (if using workflow) or create manually:
```bash
# For workflow-created PRs - wait and find it
sleep 30
# Look for PR from comfy-pr-bot (not github-actions)
PR_NUMBER=$(gh pr list --author comfy-pr-bot --limit 1 --json number --jq '.[0].number')
# Verify we got the PR
if [ -z "$PR_NUMBER" ]; then
echo "PR not found yet. Checking recent PRs..."
gh pr list --limit 5 --json number,title,author
fi
# For manual PRs
gh pr create --title "${NEW_VERSION}" \
--body-file enhanced-pr-description.md \
--label "Release"
```
3. **Create enhanced PR description:**
```bash
cat > enhanced-pr-description.md << EOF
# Release v${NEW_VERSION}
## Version Change
\`${CURRENT_VERSION}\` → \`${NEW_VERSION}\` (${VERSION_TYPE})
## Changelog
${CHANGELOG}
## Breaking Changes
${BREAKING_CHANGES}
## Testing Performed
- ✅ Full test suite (unit, component, browser)
- ✅ TypeScript compilation
- ✅ Linting checks
- ✅ Build verification
- ✅ Security audit
## Distribution Channels
- GitHub Release (with dist.zip)
- PyPI Package (comfyui-frontend-package)
- npm Package (@comfyorg/comfyui-frontend-types)
## Post-Release Tasks
- [ ] Verify all distribution channels
- [ ] Update external documentation
- [ ] Monitor for issues
EOF
```
4. Update PR with enhanced description:
```bash
gh pr edit ${PR_NUMBER} --body-file enhanced-pr-description.md
```
5. Add changelog as comment for easy reference:
```bash
gh pr comment ${PR_NUMBER} --body-file CURRENT_RELEASE_NOTES.md
```
6. **PR REVIEW**: Version bump PR created and enhanced correctly?
### Step 11: Critical Release PR Verification
1. **CRITICAL**: Verify PR has "Release" label:
```bash
gh pr view ${PR_NUMBER} --json labels | jq -r '.labels[].name' | grep -q "Release" || \
echo "ERROR: Release label missing! Add it immediately!"
```
2. Check for update-locales commits:
```bash
# WARNING: update-locales may add [skip ci] which blocks release workflow!
gh pr view ${PR_NUMBER} --json commits | grep -q "skip ci" && \
echo "WARNING: [skip ci] detected - release workflow may not trigger!"
```
3. Verify version number in package.json
4. Review all changed files
5. Ensure no unintended changes included
6. Wait for required PR checks:
```bash
gh pr checks ${PR_NUMBER} --watch
```
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
### Step 12: Pre-Merge Validation
1. **Review Requirements**: Release PRs require approval
2. Monitor CI checks - watch for update-locales
3. **CRITICAL WARNING**: If update-locales adds [skip ci], the release workflow won't trigger!
4. Check no new commits to main since PR creation
5. **DEPLOYMENT READINESS**: Ready to merge?
### Step 13: Execute Release
1. **FINAL CONFIRMATION**: Merge PR to trigger release?
2. Merge the Release PR:
```bash
gh pr merge ${PR_NUMBER} --merge
```
3. **IMMEDIATELY CHECK**: Did release workflow trigger?
```bash
sleep 10
gh run list --workflow=release.yaml --limit=1
```
4. If workflow didn't trigger due to [skip ci]:
```bash
echo "ERROR: Release workflow didn't trigger!"
echo "Options:"
echo "1. Create patch release (e.g., 1.24.1) to trigger workflow"
echo "2. Investigate manual release options"
```
5. If workflow triggered, monitor execution:
```bash
WORKFLOW_RUN_ID=$(gh run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[0].databaseId')
gh run watch ${WORKFLOW_RUN_ID}
```
### Step 14: Enhance GitHub Release
1. Wait for automatic release creation:
```bash
# Wait for release to be created
while ! gh release view v${NEW_VERSION} >/dev/null 2>&1; do
echo "Waiting for release creation..."
sleep 10
done
```
2. **Enhance the GitHub release:**
```bash
# Update release with our enhanced notes
gh release edit v${NEW_VERSION} \
--title "🚀 ComfyUI Frontend v${NEW_VERSION}" \
--notes-file github-release-notes-${NEW_VERSION}.md \
--latest
# Add any additional assets if needed
# gh release upload v${NEW_VERSION} additional-assets.zip
```
3. **Verify release details:**
```bash
gh release view v${NEW_VERSION}
```
### Step 15: Verify Multi-Channel Distribution
1. **GitHub Release:**
```bash
gh release view v${NEW_VERSION} --json assets,body,createdAt,tagName
```
- ✅ Check release notes
- ✅ Verify dist.zip attachment
- ✅ Confirm release marked as latest (for main branch)
2. **PyPI Package:**
```bash
# Check PyPI availability (may take a few minutes)
for i in {1..10}; do
if curl -s https://pypi.org/pypi/comfyui-frontend-package/json | jq -r '.releases | keys[]' | grep -q ${NEW_VERSION}; then
echo "✅ PyPI package available"
break
fi
echo "⏳ Waiting for PyPI package... (attempt $i/10)"
sleep 30
done
```
3. **npm Package:**
```bash
# Check npm availability
for i in {1..10}; do
if npm view @comfyorg/comfyui-frontend-types@${NEW_VERSION} version >/dev/null 2>&1; then
echo "✅ npm package available"
break
fi
echo "⏳ Waiting for npm package... (attempt $i/10)"
sleep 30
done
```
4. **DISTRIBUTION VERIFICATION**: All channels published successfully?
### Step 16: Post-Release Monitoring Setup
1. **Monitor immediate release health:**
```bash
# Check for immediate issues
gh issue list --label "bug" --state open --limit 5 --json title,number,createdAt
# Monitor download metrics (if accessible)
gh release view v${NEW_VERSION} --json assets --jq '.assets[].downloadCount'
```
2. **Update documentation tracking:**
```bash
cat > post-release-checklist.md << EOF
# Post-Release Checklist for v${NEW_VERSION}
## Immediate Tasks (Next 24 hours)
- [ ] Monitor error rates and user feedback
- [ ] Watch for critical issues
- [ ] Verify documentation is up to date
- [ ] Check community channels for questions
## Short-term Tasks (Next week)
- [ ] Update external integration guides
- [ ] Monitor adoption metrics
- [ ] Gather user feedback
- [ ] Plan next release cycle
## Long-term Tasks
- [ ] Analyze release process improvements
- [ ] Update release templates based on learnings
- [ ] Document any new patterns discovered
## Key Metrics to Track
- Download counts: GitHub, PyPI, npm
- Issue reports related to v${NEW_VERSION}
- Community feedback and adoption
- Performance impact measurements
EOF
```
3. **Create release summary:**
```bash
cat > release-summary-${NEW_VERSION}.md << EOF
# Release Summary: ComfyUI Frontend v${NEW_VERSION}
**Released:** $(date)
**Type:** ${VERSION_TYPE}
**Duration:** ~${RELEASE_DURATION} minutes
**Release Commit:** ${RELEASE_COMMIT}
## Metrics
- **Commits Included:** ${COMMITS_COUNT}
- **Contributors:** ${CONTRIBUTORS_COUNT}
- **Files Changed:** ${FILES_CHANGED}
- **Lines Added/Removed:** +${LINES_ADDED}/-${LINES_REMOVED}
## Distribution Status
- ✅ GitHub Release: Published
- ✅ PyPI Package: Available
- ✅ npm Types: Available
## Next Steps
- Monitor for 24-48 hours
- Address any critical issues immediately
- Plan next release cycle
## Files Generated
- \`release-notes-${NEW_VERSION}-$(date +%Y%m%d).md\` - Detailed changelog
- \`github-release-notes-${NEW_VERSION}.md\` - GitHub release notes
- \`post-release-checklist.md\` - Follow-up tasks
EOF
```
4. **RELEASE COMPLETION**: All post-release setup completed?
## Advanced Safety Features
### Rollback Procedures
**Pre-Merge Rollback:**
```bash
# Close version bump PR and reset
gh pr close ${PR_NUMBER}
git reset --hard origin/main
git clean -fd
```
**Post-Merge Rollback:**
```bash
# Create immediate patch release with reverts
git revert ${RELEASE_COMMIT}
# Follow this command again with patch version
```
**Emergency Procedures:**
```bash
# Document incident
cat > release-incident-${NEW_VERSION}.md << EOF
# Release Incident Report
**Version:** ${NEW_VERSION}
**Issue:** [Describe the problem]
**Impact:** [Severity and scope]
**Resolution:** [Steps taken]
**Prevention:** [Future improvements]
EOF
# Contact package registries for critical issues
echo "For critical security issues, consider:"
echo "- PyPI: Contact support for package yanking"
echo "- npm: Use 'npm unpublish' within 72 hours"
echo "- GitHub: Update release with warning notes"
```
### Quality Gates Summary
The command implements multiple quality gates:
1. **🔒 Security Gate**: Vulnerability scanning, secret detection
2. **🧪 Quality Gate**: Full test suite, linting, type checking
3. **📋 Content Gate**: Changelog accuracy, release notes quality
4. **🔄 Process Gate**: Release timing verification
5. **✅ Verification Gate**: Multi-channel publishing confirmation
6. **📊 Monitoring Gate**: Post-release health tracking
## Common Scenarios
### Scenario 1: Regular Feature Release
```bash
/project:create-frontend-release minor
```
- Analyzes features since last release
- Generates changelog automatically
- Creates comprehensive release notes
### Scenario 2: Critical Security Patch
```bash
/project:create-frontend-release patch "Security fixes for CVE-2024-XXXX"
```
- Expedited security scanning
- Enhanced monitoring setup
### Scenario 3: Major Version with Breaking Changes
```bash
/project:create-frontend-release major
```
- Comprehensive breaking change analysis
- Migration guide generation
### Scenario 4: Pre-release Testing
```bash
/project:create-frontend-release prerelease
```
- Creates alpha/beta/rc versions
- Draft release status
- Python package specs require that prereleases use alpha/beta/rc as the preid
## Common Issues and Solutions
### Issue: Pre-release Version Confusion
**Problem**: Not sure whether to promote pre-release or create new version
**Solution**:
- Follow semver standards: a prerelease version is followed by a normal release. It should have the same major, minor, and patch versions as the prerelease.
### Issue: Wrong Commit Count
**Problem**: Changelog includes commits from other branches
**Solution**: Always use `--first-parent` flag with git log
**Update**: Sometimes update-locales doesn't add [skip ci] - always verify!
### Issue: Missing PRs in Changelog
**Problem**: PR was merged to different branch
**Solution**: Verify PR merge target with:
```bash
gh pr view ${PR_NUMBER} --json baseRefName
```
### Issue: Release Failed Due to [skip ci]
**Problem**: Release workflow didn't trigger after merge
**Prevention**: Always avoid this scenario
- Ensure that `[skip ci]` or similar flags are NOT in the `HEAD` commit message of the PR
- Push a new, empty commit to the PR
- Always double-check this immediately before merging
**Recovery Strategy**:
1. Revert version in a new PR (e.g., 1.24.0 → 1.24.0-1)
2. Merge the revert PR
3. Run version bump workflow again
4. This creates a fresh PR without [skip ci]
Benefits: Cleaner than creating extra version numbers
## Key Learnings & Notes
1. **PR Author**: Version bump PRs are created by `comfy-pr-bot`, not `github-actions`
2. **Workflow Speed**: Version bump workflow typically completes in ~20-30 seconds
3. **Update-locales Behavior**: Inconsistent - sometimes adds [skip ci], sometimes doesn't
4. **Recovery Options**: Reverting version is cleaner than creating extra versions

View File

@@ -0,0 +1,222 @@
# Create Hotfix Release
This command guides you through creating a patch/hotfix release for ComfyUI Frontend with comprehensive safety checks and human confirmations at each step.
<task>
Create a hotfix release by cherry-picking commits or PR commits from main to a core branch: $ARGUMENTS
Expected format: Comma-separated list of commits or PR numbers
Examples:
- `abc123,def456,ghi789` (commits)
- `#1234,#5678` (PRs)
- `abc123,#1234,def456` (mixed)
If no arguments provided, the command will help identify the correct core branch and guide you through selecting commits/PRs.
</task>
## Prerequisites
Before starting, ensure:
- You have push access to the repository
- GitHub CLI (`gh`) is authenticated
- You're on a clean working tree
- You understand the commits/PRs you're cherry-picking
## Hotfix Release Process
### Step 1: Identify Target Core Branch
1. Fetch the current ComfyUI requirements.txt from master branch:
```bash
curl -s https://raw.githubusercontent.com/comfyanonymous/ComfyUI/master/requirements.txt | grep "comfyui-frontend-package"
```
2. Extract the `comfyui-frontend-package` version (e.g., `comfyui-frontend-package==1.23.4`)
3. Parse version to get major.minor (e.g., `1.23.4` → `1.23`)
4. Determine core branch: `core/<major>.<minor>` (e.g., `core/1.23`)
5. Verify the core branch exists: `git ls-remote origin refs/heads/core/*`
6. **CONFIRMATION REQUIRED**: Is `core/X.Y` the correct target branch?
### Step 2: Parse and Validate Arguments
1. Parse the comma-separated list of commits/PRs
2. For each item:
- If starts with `#`: Treat as PR number
- Otherwise: Treat as commit hash
3. For PR numbers:
- Fetch PR details using `gh pr view <number>`
- Extract the merge commit if PR is merged
- If PR has multiple commits, list them all
- **CONFIRMATION REQUIRED**: Use merge commit or cherry-pick individual commits?
4. Validate all commit hashes exist in the repository
### Step 3: Analyze Target Changes
1. For each commit/PR to cherry-pick:
- Display commit hash, author, date
- Show PR title and number (if applicable)
- Display commit message
- Show files changed and diff statistics
- Check if already in core branch: `git branch --contains <commit>`
2. Identify potential conflicts by checking changed files
3. **CONFIRMATION REQUIRED**: Proceed with these commits?
### Step 4: Create Hotfix Branch
1. Checkout the core branch (e.g., `core/1.23`)
2. Pull latest changes: `git pull origin core/X.Y`
3. Display current version from package.json
4. Create hotfix branch: `hotfix/<version>-<timestamp>`
- Example: `hotfix/1.23.4-20241120`
5. **CONFIRMATION REQUIRED**: Created branch correctly?
### Step 5: Cherry-pick Changes
For each commit:
1. Attempt cherry-pick: `git cherry-pick <commit>`
2. If conflicts occur:
- Display conflict details
- Show conflicting sections
- Provide resolution guidance
- **CONFIRMATION REQUIRED**: Conflicts resolved correctly?
3. After successful cherry-pick:
- Show the changes: `git show HEAD`
- Run validation: `npm run typecheck && npm run lint`
4. **CONFIRMATION REQUIRED**: Cherry-pick successful and valid?
### Step 6: Create PR to Core Branch
1. Push the hotfix branch: `git push origin hotfix/<version>-<timestamp>`
2. Create PR using gh CLI:
```bash
gh pr create --base core/X.Y --head hotfix/<version>-<timestamp> \
--title "[Hotfix] Cherry-pick fixes to core/X.Y" \
--body "Cherry-picked commits: ..."
```
3. Add appropriate labels (but NOT "Release" yet)
4. PR body should include:
- List of cherry-picked commits/PRs
- Original issue references
- Testing instructions
- Impact assessment
5. **CONFIRMATION REQUIRED**: PR created correctly?
### Step 7: Wait for Tests
1. Monitor PR checks: `gh pr checks`
2. Display test results as they complete
3. If any tests fail:
- Show failure details
- Analyze if related to cherry-picks
- **DECISION REQUIRED**: Fix and continue, or abort?
4. Wait for all required checks to pass
5. **CONFIRMATION REQUIRED**: All tests passing?
### Step 8: Merge Hotfix PR
1. Verify all checks have passed
2. Check for required approvals
3. Merge the PR: `gh pr merge --merge`
4. Delete the hotfix branch
5. **CONFIRMATION REQUIRED**: PR merged successfully?
### Step 9: Create Version Bump
1. Checkout the core branch: `git checkout core/X.Y`
2. Pull latest changes: `git pull origin core/X.Y`
3. Read current version from package.json
4. Determine patch version increment:
- Current: `1.23.4` → New: `1.23.5`
5. Create release branch named with new version: `release/1.23.5`
6. Update version in package.json to `1.23.5`
7. Commit: `git commit -m "[release] Bump version to 1.23.5"`
8. **CONFIRMATION REQUIRED**: Version bump correct?
### Step 10: Create Release PR
1. Push release branch: `git push origin release/1.23.5`
2. Create PR with Release label:
```bash
gh pr create --base core/X.Y --head release/1.23.5 \
--title "[Release] v1.23.5" \
--body "..." \
--label "Release"
```
3. **CRITICAL**: Verify "Release" label is added
4. PR description should include:
- Version: `1.23.4` → `1.23.5`
- Included fixes (link to previous PR)
- Release notes for users
5. **CONFIRMATION REQUIRED**: Release PR has "Release" label?
### Step 11: Monitor Release Process
1. Wait for PR checks to pass
2. **FINAL CONFIRMATION**: Ready to trigger release by merging?
3. Merge the PR: `gh pr merge --merge`
4. Monitor release workflow:
```bash
gh run list --workflow=release.yaml --limit=1
gh run watch
```
5. Track progress:
- GitHub release draft/publication
- PyPI upload
- npm types publication
### Step 12: Post-Release Verification
1. Verify GitHub release:
```bash
gh release view v1.23.5
```
2. Check PyPI package:
```bash
pip index versions comfyui-frontend-package | grep 1.23.5
```
3. Verify npm package:
```bash
npm view @comfyorg/comfyui-frontend-types@1.23.5
```
4. Generate release summary with:
- Version released
- Commits included
- Issues fixed
- Distribution status
5. **CONFIRMATION REQUIRED**: Release completed successfully?
## Safety Checks
Throughout the process:
- Always verify core branch matches ComfyUI's requirements.txt
- For PRs: Ensure using correct commits (merge vs individual)
- Check version numbers follow semantic versioning
- **Critical**: "Release" label must be on version bump PR
- Validate cherry-picks don't break core branch stability
- Keep audit trail of all operations
## Rollback Procedures
If something goes wrong:
- Before push: `git reset --hard origin/core/X.Y`
- After PR creation: Close PR and start over
- After failed release: Create new patch version with fixes
- Document any issues for future reference
## Important Notes
- Core branch version will be behind main - this is expected
- The "Release" label triggers the PyPI/npm publication
- PR numbers must include the `#` prefix
- Mixed commits/PRs are supported but review carefully
- Always wait for full test suite before proceeding
## Expected Timeline
- Step 1-3: ~10 minutes (analysis)
- Steps 4-6: ~15-30 minutes (cherry-picking)
- Step 7: ~10-20 minutes (tests)
- Steps 8-10: ~10 minutes (version bump)
- Step 11-12: ~15-20 minutes (release)
- Total: ~60-90 minutes
This process ensures a safe, verified hotfix release with multiple confirmation points and clear tracking of what changes are being released.

36
.github/CLAUDE.md vendored Normal file
View File

@@ -0,0 +1,36 @@
# ComfyUI Frontend - Claude Review Context
This file provides additional context for the automated PR review system.
## Quick Reference
### PrimeVue Component Migrations
When reviewing, flag these deprecated components:
- `Dropdown` → Use `Select` from 'primevue/select'
- `OverlayPanel` → Use `Popover` from 'primevue/popover'
- `Calendar` → Use `DatePicker` from 'primevue/datepicker'
- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch'
- `Sidebar` → Use `Drawer` from 'primevue/drawer'
- `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
- `TabMenu` → Use `Tabs` without panels
- `Steps` → Use `Stepper` without panels
- `InlineMessage` → Use `Message` component
### API Utilities Reference
- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.)
- `api.fileURL()` - Static file access (templates, extensions)
- `$t()` / `i18n.global.t()` - Internationalization
- `DOMPurify.sanitize()` - HTML sanitization
## Review Scope
This automated review performs comprehensive analysis including:
- Architecture and design patterns
- Security vulnerabilities
- Performance implications
- Code quality and maintainability
- Integration concerns
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.

75
.github/workflows/claude-pr-review.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Claude PR Review
permissions:
contents: read
pull-requests: write
issues: write
on:
pull_request:
types: [labeled]
jobs:
wait-for-ci:
runs-on: ubuntu-latest
if: github.event.label.name == 'claude-review'
outputs:
should-proceed: ${{ steps.check-status.outputs.proceed }}
steps:
- name: Wait for other CI checks
uses: lewagon/wait-on-check-action@v1.3.1
with:
ref: ${{ github.event.pull_request.head.sha }}
check-regexp: '^(ESLint|Prettier Check|Tests CI|Vitest Tests)'
wait-interval: 30
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if we should proceed
id: check-status
run: |
# Get all check runs for this commit
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("ESLint|Prettier Check|Tests CI|Vitest Tests")) | {name, conclusion}')
# Check if any required checks failed
if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then
echo "Some CI checks failed - skipping Claude review"
echo "proceed=false" >> $GITHUB_OUTPUT
else
echo "All CI checks passed - proceeding with Claude review"
echo "proceed=true" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
claude-review:
needs: wait-for-ci
if: needs.wait-for-ci.outputs.should-proceed == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies for analysis tools
run: |
npm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@main
with:
prompt_file: .claude/commands/comprehensive-pr-review.md
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
max_turns: 1
timeout_minutes: 30
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
REPOSITORY: ${{ github.repository }}

154
.github/workflows/pr-checks.yml vendored Normal file
View File

@@ -0,0 +1,154 @@
name: PR Checks
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
analyze:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check-changes.outputs.should_run }}
has_browser_tests: ${{ steps.check-coverage.outputs.has_browser_tests }}
has_screen_recording: ${{ steps.check-recording.outputs.has_recording }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure base branch is available
run: |
# Fetch the specific base commit to ensure it's available for git diff
git fetch origin ${{ github.event.pull_request.base.sha }}
- name: Check if significant changes exist
id: check-changes
run: |
# Get list of changed files
CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }})
# Filter for src/ files
SRC_FILES=$(echo "$CHANGED_FILES" | grep '^src/' || true)
if [ -z "$SRC_FILES" ]; then
echo "No src/ files changed"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Count lines changed in src files
TOTAL_LINES=0
for file in $SRC_FILES; do
if [ -f "$file" ]; then
# Count added lines (non-empty)
ADDED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^+' | grep -v '^+++' | grep -v '^+$' | wc -l)
# Count removed lines (non-empty)
REMOVED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^-' | grep -v '^---' | grep -v '^-$' | wc -l)
TOTAL_LINES=$((TOTAL_LINES + ADDED + REMOVED))
fi
done
echo "Total lines changed in src/: $TOTAL_LINES"
if [ $TOTAL_LINES -gt 3 ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
fi
- name: Check browser test coverage
id: check-coverage
if: steps.check-changes.outputs.should_run == 'true'
run: |
# Check if browser tests were updated
BROWSER_TEST_CHANGES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^browser_tests/.*\.ts$' || true)
if [ -n "$BROWSER_TEST_CHANGES" ]; then
echo "has_browser_tests=true" >> "$GITHUB_OUTPUT"
else
echo "has_browser_tests=false" >> "$GITHUB_OUTPUT"
fi
- name: Check for screen recording
id: check-recording
if: steps.check-changes.outputs.should_run == 'true'
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
# Check PR body for screen recording
# Check for GitHub user attachments or YouTube links
if echo "$PR_BODY" | grep -qiE 'github\.com/user-attachments/assets/[a-f0-9-]+|youtube\.com/watch|youtu\.be/'; then
echo "has_recording=true" >> "$GITHUB_OUTPUT"
else
echo "has_recording=false" >> "$GITHUB_OUTPUT"
fi
- name: Final check and create results
id: final-check
if: always()
run: |
# Initialize results
WARNINGS_JSON=""
# Only run checks if should_run is true
if [ "${{ steps.check-changes.outputs.should_run }}" == "true" ]; then
# Check browser test coverage
if [ "${{ steps.check-coverage.outputs.has_browser_tests }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: E2E Test Coverage Missing**\\n\\nIf this PR modifies behavior that can be covered by browser-based E2E tests, those tests are required. PRs lacking applicable test coverage may not be reviewed until added. Please add or update browser tests to ensure code quality and prevent regressions.\"}"
fi
# Check screen recording
if [ "${{ steps.check-recording.outputs.has_recording }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: Visual Documentation Missing**\\n\\nIf this PR changes user-facing behavior, visual proof (screen recording or screenshot) is required. PRs without applicable visual documentation may not be reviewed until provided.\\nYou can add it by:\\n\\n- GitHub: Drag & drop media directly into the PR description\\n\\n- YouTube: Include a link to a short demo\"}"
fi
fi
# Create results JSON
if [ -n "$WARNINGS_JSON" ]; then
# Create JSON with warnings
cat > pr-check-results.json << EOF
{
"fails": [],
"warnings": [$WARNINGS_JSON],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
else
# Create JSON with success
cat > pr-check-results.json << 'EOF'
{
"fails": [],
"warnings": [],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
fi
# Write PR metadata
echo "${{ github.event.pull_request.number }}" > pr-number.txt
echo "${{ github.event.pull_request.head.sha }}" > pr-sha.txt
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: pr-check-results-${{ github.run_id }}
path: |
pr-check-results.json
pr-number.txt
pr-sha.txt
retention-days: 1

149
.github/workflows/pr-comment.yml vendored Normal file
View File

@@ -0,0 +1,149 @@
name: PR Comment
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
permissions:
pull-requests: write
issues: write
statuses: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: pr-check-results-${{ github.event.workflow_run.id }}
path: /tmp/pr-artifacts
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Post results
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Helper function to safely read files
function safeReadFile(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf8').trim();
} catch (e) {
console.error(`Error reading ${filePath}:`, e);
return null;
}
}
// Read artifact files
const artifactDir = '/tmp/pr-artifacts';
const prNumber = safeReadFile(path.join(artifactDir, 'pr-number.txt'));
const prSha = safeReadFile(path.join(artifactDir, 'pr-sha.txt'));
const resultsJson = safeReadFile(path.join(artifactDir, 'pr-check-results.json'));
// Validate PR number
if (!prNumber || isNaN(parseInt(prNumber))) {
throw new Error('Invalid or missing PR number');
}
// Parse and validate results
let results;
try {
results = JSON.parse(resultsJson || '{}');
} catch (e) {
console.error('Failed to parse check results:', e);
// Post error comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: `⚠️ PR checks failed to complete properly. Error parsing results: ${e.message}`
});
return;
}
// Format check messages
const messages = [];
if (results.fails && results.fails.length > 0) {
messages.push('### ❌ Failures\n' + results.fails.map(f => f.message).join('\n\n'));
}
if (results.warnings && results.warnings.length > 0) {
messages.push('### ⚠️ Warnings\n' + results.warnings.map(w => w.message).join('\n\n'));
}
if (results.messages && results.messages.length > 0) {
messages.push('### 💬 Messages\n' + results.messages.map(m => m.message).join('\n\n'));
}
if (results.markdowns && results.markdowns.length > 0) {
messages.push(...results.markdowns.map(m => m.message));
}
// Find existing bot comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber)
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('<!-- pr-checks-comment -->')
);
// Post comment if there are any messages
if (messages.length > 0) {
const body = messages.join('\n\n');
const commentBody = `<!-- pr-checks-comment -->\n${body}`;
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: commentBody
});
}
} else {
// No messages - delete existing comment if present
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id
});
}
}
// Set commit status based on failures
if (prSha) {
const hasFailures = results.fails && results.fails.length > 0;
const hasWarnings = results.warnings && results.warnings.length > 0;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: prSha,
state: hasFailures ? 'failure' : 'success',
context: 'pr-checks',
description: hasFailures
? `${results.fails.length} check(s) failed`
: hasWarnings
? `${results.warnings.length} warning(s)`
: 'All checks passed'
});
}

View File

@@ -9,9 +9,10 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
Note: For Traditional Chinese (Taiwan), use Taiwan-specific terminology and traditional characters.
`
});

View File

@@ -529,7 +529,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack
- **Required Software**:
- Node.js (v16 or later) and npm
- Node.js (v16 or later; v20/v22 strongly recommended) and npm
- Git for version control
- A running ComfyUI backend instance

View File

@@ -14,7 +14,7 @@ Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` dir
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
```bash
npx playwright install chromium --with-deps
```

View File

@@ -0,0 +1,716 @@
{
"id": "976d6e9a-927d-42db-abd4-96bfc0ecf8d9",
"revision": 0,
"last_node_id": 10,
"last_link_id": 11,
"nodes": [
{
"id": 10,
"type": "8beb610f-ddd1-4489-ae0d-2f732a4042ae",
"pos": [
532,
412.5
],
"size": [
140,
46
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
10
]
},
{
"name": "VAE",
"type": "VAE",
"links": [
11
]
}
],
"title": "subgraph 2",
"properties": {},
"widgets_values": []
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
758.2109985351562,
398.3681335449219
],
"size": [
210,
46
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 10
},
{
"name": "vae",
"type": "VAE",
"link": 11
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1028.9615478515625,
381.83746337890625
],
"size": [
210,
270
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"ComfyUI"
]
}
],
"links": [
[
9,
8,
0,
9,
0,
"IMAGE"
],
[
10,
10,
0,
8,
0,
"LATENT"
],
[
11,
10,
1,
8,
1,
"VAE"
]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "8beb610f-ddd1-4489-ae0d-2f732a4042ae",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 14,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "subgraph 2",
"inputNode": {
"id": -10,
"bounding": [
-154,
415.5,
120,
40
]
},
"outputNode": {
"id": -20,
"bounding": [
1238,
395.5,
120,
80
]
},
"inputs": [],
"outputs": [
{
"id": "4d6c7e4e-971e-4f78-9218-9a604db53a4b",
"name": "LATENT",
"type": "LATENT",
"linkIds": [
7
],
"localized_name": "LATENT",
"pos": {
"0": 1258,
"1": 415.5
}
},
{
"id": "f8201d4f-7fc6-4a1b-b8c9-9f0716d9c09a",
"name": "VAE",
"type": "VAE",
"linkIds": [
14
],
"localized_name": "VAE",
"pos": {
"0": 1258,
"1": 435.5
}
}
],
"widgets": [],
"nodes": [
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
415,
186
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 13
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": [
315,
262
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 10
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 11
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
32115495257102,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 10,
"type": "dbe5763f-440b-47b4-82ac-454f1f98b0e3",
"pos": [
194.13900756835938,
657.3333740234375
],
"size": [
140,
106
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
10
]
},
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [
11
]
},
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"links": [
12
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
13
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
14
]
}
],
"title": "subgraph 3",
"properties": {},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": 6,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 10,
"origin_id": 10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 11,
"origin_id": 10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 12,
"origin_id": 10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": 10,
"origin_slot": 3,
"target_id": 6,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 14,
"origin_id": 10,
"origin_slot": 4,
"target_id": -20,
"target_slot": 1,
"type": "VAE"
}
],
"extra": {}
},
{
"id": "dbe5763f-440b-47b4-82ac-454f1f98b0e3",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 9,
"lastLinkId": 9,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "subgraph 3",
"inputNode": {
"id": -10,
"bounding": [
-154,
517,
120,
40
]
},
"outputNode": {
"id": -20,
"bounding": [
898.2780151367188,
467,
128.6640625,
140
]
},
"inputs": [],
"outputs": [
{
"id": "b4882169-329b-43f6-a373-81abfbdea55b",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [
6
],
"localized_name": "CONDITIONING",
"pos": {
"0": 918.2780151367188,
"1": 487
}
},
{
"id": "01f51f96-a741-428e-8772-9557ee50b609",
"name": "LATENT",
"type": "LATENT",
"linkIds": [
2
],
"localized_name": "LATENT",
"pos": {
"0": 918.2780151367188,
"1": 507
}
},
{
"id": "47fa906e-d80b-45c3-a596-211a0e59d4a1",
"name": "MODEL",
"type": "MODEL",
"linkIds": [
1
],
"localized_name": "MODEL",
"pos": {
"0": 918.2780151367188,
"1": 527
}
},
{
"id": "f03dccd7-10e8-4513-9994-15854a92d192",
"name": "CLIP",
"type": "CLIP",
"linkIds": [
3
],
"localized_name": "CLIP",
"pos": {
"0": 918.2780151367188,
"1": 547
}
},
{
"id": "a666877f-e34f-49bc-8a78-b26156656b83",
"name": "VAE",
"type": "VAE",
"linkIds": [
8
],
"localized_name": "VAE",
"pos": {
"0": 918.2780151367188,
"1": 567
}
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"text, watermark"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
473,
609
],
"size": [
315,
106
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
2
]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
512,
512,
1
]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
26,
474
],
"size": [
315,
98
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
1
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
3,
5
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly-fp16.safetensors"
]
}
],
"groups": [],
"links": [
{
"id": 5,
"origin_id": 4,
"origin_slot": 1,
"target_id": 7,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 6,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 5,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "LATENT"
},
{
"id": 1,
"origin_id": 4,
"origin_slot": 0,
"target_id": -20,
"target_slot": 2,
"type": "MODEL"
},
{
"id": 3,
"origin_id": 4,
"origin_slot": 1,
"target_id": -20,
"target_slot": 3,
"type": "CLIP"
},
{
"id": 8,
"origin_id": 4,
"origin_slot": 2,
"target_id": -20,
"target_slot": 4,
"type": "VAE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.24.0-1"
},
"version": 0.4
}

View File

@@ -0,0 +1,382 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Feature Flags', () => {
test('Client and server exchange feature flags on connection', async ({
comfyPage
}) => {
// Navigate to a new page to capture the initial WebSocket connection
const newPage = await comfyPage.page.context().newPage()
// Set up monitoring before navigation
await newPage.addInitScript(() => {
// This runs before any page scripts
window.__capturedMessages = {
clientFeatureFlags: null,
serverFeatureFlags: null
}
// Capture outgoing client messages
const originalSend = WebSocket.prototype.send
WebSocket.prototype.send = function (data) {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'feature_flags') {
window.__capturedMessages.clientFeatureFlags = parsed
}
} catch (e) {
// Not JSON, ignore
}
return originalSend.call(this, data)
}
// Monitor for server feature flags
const checkInterval = setInterval(() => {
if (
window['app']?.api?.serverFeatureFlags &&
Object.keys(window['app'].api.serverFeatureFlags).length > 0
) {
window.__capturedMessages.serverFeatureFlags =
window['app'].api.serverFeatureFlags
clearInterval(checkInterval)
}
}, 100)
// Clear after 10 seconds
setTimeout(() => clearInterval(checkInterval), 10000)
})
// Navigate to the app
await newPage.goto(comfyPage.url)
// Wait for both client and server feature flags
await newPage.waitForFunction(
() =>
window.__capturedMessages.clientFeatureFlags !== null &&
window.__capturedMessages.serverFeatureFlags !== null,
{ timeout: 10000 }
)
// Get the captured messages
const messages = await newPage.evaluate(() => window.__capturedMessages)
// Verify client sent feature flags
expect(messages.clientFeatureFlags).toBeTruthy()
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
expect(messages.clientFeatureFlags).toHaveProperty('data')
expect(messages.clientFeatureFlags.data).toHaveProperty(
'supports_preview_metadata'
)
expect(
typeof messages.clientFeatureFlags.data.supports_preview_metadata
).toBe('boolean')
// Verify server sent feature flags back
expect(messages.serverFeatureFlags).toBeTruthy()
expect(messages.serverFeatureFlags).toHaveProperty(
'supports_preview_metadata'
)
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
'boolean'
)
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
await newPage.close()
})
test('Server feature flags are received and accessible', async ({
comfyPage
}) => {
// Wait for connection to establish
await comfyPage.page.waitForTimeout(1000)
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window['app'].api.serverFeatureFlags
})
// Verify we received real feature flags from the backend
expect(serverFlags).toBeTruthy()
expect(Object.keys(serverFlags).length).toBeGreaterThan(0)
// The backend should send feature flags
expect(serverFlags).toHaveProperty('supports_preview_metadata')
expect(typeof serverFlags.supports_preview_metadata).toBe('boolean')
expect(serverFlags).toHaveProperty('max_upload_size')
expect(typeof serverFlags.max_upload_size).toBe('number')
})
test('serverSupportsFeature method works with real backend flags', async ({
comfyPage
}) => {
// Wait for connection
await comfyPage.page.waitForTimeout(1000)
// Test serverSupportsFeature with real backend flags
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
return window['app'].api.serverSupportsFeature(
'supports_preview_metadata'
)
})
// The method should return a boolean based on the backend's value
expect(typeof supportsPreviewMetadata).toBe('boolean')
// Test non-existent feature - should always return false
const supportsNonExistent = await comfyPage.page.evaluate(() => {
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
})
expect(supportsNonExistent).toBe(false)
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window['app'].api.serverFeatureFlags
window['app'].api.serverFeatureFlags = {
bool_true: true,
bool_false: false,
string_value: 'yes',
number_value: 1,
null_value: null
}
const results = {
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
string_value: window['app'].api.serverSupportsFeature('string_value'),
number_value: window['app'].api.serverSupportsFeature('number_value'),
null_value: window['app'].api.serverSupportsFeature('null_value')
}
// Restore original
window['app'].api.serverFeatureFlags = original
return results
})
// serverSupportsFeature should only return true for boolean true values
expect(testResults.bool_true).toBe(true)
expect(testResults.bool_false).toBe(false)
expect(testResults.string_value).toBe(false)
expect(testResults.number_value).toBe(false)
expect(testResults.null_value).toBe(false)
})
test('getServerFeature method works with real backend data', async ({
comfyPage
}) => {
// Wait for connection
await comfyPage.page.waitForTimeout(1000)
// Test getServerFeature method
const previewMetadataValue = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeature('supports_preview_metadata')
})
expect(typeof previewMetadataValue).toBe('boolean')
// Test getting max_upload_size
const maxUploadSize = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeature('max_upload_size')
})
expect(typeof maxUploadSize).toBe('number')
expect(maxUploadSize).toBeGreaterThan(0)
// Test getServerFeature with default value for non-existent feature
const defaultValue = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeature(
'non_existent_feature_xyz',
'default'
)
})
expect(defaultValue).toBe('default')
})
test('getServerFeatures returns all backend feature flags', async ({
comfyPage
}) => {
// Wait for connection
await comfyPage.page.waitForTimeout(1000)
// Test getServerFeatures returns all flags
const allFeatures = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeatures()
})
expect(allFeatures).toBeTruthy()
expect(allFeatures).toHaveProperty('supports_preview_metadata')
expect(typeof allFeatures.supports_preview_metadata).toBe('boolean')
expect(allFeatures).toHaveProperty('max_upload_size')
expect(Object.keys(allFeatures).length).toBeGreaterThan(0)
})
test('Client feature flags are immutable', async ({ comfyPage }) => {
// Test that getClientFeatureFlags returns a copy
const immutabilityTest = await comfyPage.page.evaluate(() => {
const flags1 = window['app'].api.getClientFeatureFlags()
const flags2 = window['app'].api.getClientFeatureFlags()
// Modify the first object
flags1.test_modification = true
// Get flags again to check if original was modified
const flags3 = window['app'].api.getClientFeatureFlags()
return {
areEqual: flags1 === flags2,
hasModification: flags3.test_modification !== undefined,
hasSupportsPreview: flags1.supports_preview_metadata !== undefined,
supportsPreviewValue: flags1.supports_preview_metadata
}
})
// Verify they are different objects (not the same reference)
expect(immutabilityTest.areEqual).toBe(false)
// Verify modification didn't affect the original
expect(immutabilityTest.hasModification).toBe(false)
// Verify the flags contain expected properties
expect(immutabilityTest.hasSupportsPreview).toBe(true)
expect(typeof immutabilityTest.supportsPreviewValue).toBe('boolean') // From clientFeatureFlags.json
})
test('Server features are immutable when accessed via getServerFeatures', async ({
comfyPage
}) => {
// Wait for connection to establish
await comfyPage.page.waitForTimeout(1000)
const immutabilityTest = await comfyPage.page.evaluate(() => {
// Get a copy of server features
const features1 = window['app'].api.getServerFeatures()
// Try to modify it
features1.supports_preview_metadata = false
features1.new_feature = 'added'
// Get another copy
const features2 = window['app'].api.getServerFeatures()
return {
modifiedValue: features1.supports_preview_metadata,
originalValue: features2.supports_preview_metadata,
hasNewFeature: features2.new_feature !== undefined,
hasSupportsPreview: features2.supports_preview_metadata !== undefined
}
})
// The modification should only affect the copy
expect(immutabilityTest.modifiedValue).toBe(false)
expect(typeof immutabilityTest.originalValue).toBe('boolean') // Backend sends boolean for supports_preview_metadata
expect(immutabilityTest.hasNewFeature).toBe(false)
expect(immutabilityTest.hasSupportsPreview).toBe(true)
})
test('Feature flags are negotiated early in connection lifecycle', async ({
comfyPage
}) => {
// This test verifies that feature flags are available early in the app lifecycle
// which is important for protocol negotiation
// Create a new page to ensure clean state
const newPage = await comfyPage.page.context().newPage()
// Set up monitoring before navigation
await newPage.addInitScript(() => {
// Track when various app components are ready
;(window as any).__appReadiness = {
featureFlagsReceived: false,
apiInitialized: false,
appInitialized: false
}
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
) {
;(window as any).__appReadiness.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
}
}, 10)
// Monitor API initialization
const checkApi = setInterval(() => {
if (window['app']?.api) {
;(window as any).__appReadiness.apiInitialized = true
clearInterval(checkApi)
}
}, 10)
// Monitor app initialization
const checkApp = setInterval(() => {
if (window['app']?.graph) {
;(window as any).__appReadiness.appInitialized = true
clearInterval(checkApp)
}
}, 10)
// Clean up after 10 seconds
setTimeout(() => {
clearInterval(checkFeatureFlags)
clearInterval(checkApi)
clearInterval(checkApp)
}, 10000)
})
// Navigate to the app
await newPage.goto(comfyPage.url)
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
{
timeout: 10000
}
)
// Get readiness state
const readiness = await newPage.evaluate(() => {
return {
...(window as any).__appReadiness,
currentFlags: window['app'].api.serverFeatureFlags
}
})
// Verify feature flags are available
expect(readiness.currentFlags).toHaveProperty('supports_preview_metadata')
expect(typeof readiness.currentFlags.supports_preview_metadata).toBe(
'boolean'
)
expect(readiness.currentFlags).toHaveProperty('max_upload_size')
// Verify feature flags were received (we detected them via polling)
expect(readiness.featureFlagsReceived).toBe(true)
// Verify API was initialized (feature flags require API)
expect(readiness.apiInitialized).toBe(true)
await newPage.close()
})
test('Backend /features endpoint returns feature flags', async ({
comfyPage
}) => {
// Test the HTTP endpoint directly
const response = await comfyPage.page.request.get(
`${comfyPage.url}/api/features`
)
expect(response.ok()).toBe(true)
const features = await response.json()
expect(features).toBeTruthy()
expect(features).toHaveProperty('supports_preview_metadata')
expect(typeof features.supports_preview_metadata).toBe('boolean')
expect(features).toHaveProperty('max_upload_size')
expect(Object.keys(features).length).toBeGreaterThan(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -130,4 +130,239 @@ test.describe('Release Notifications', () => {
whatsNewSection.locator('text=No recent releases')
).toBeVisible()
})
test('should hide "What\'s New" section when notifications are disabled', async ({
comfyPage
}) => {
// Disable version update notifications
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
// Mock release API with test data
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'high',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString()
}
])
})
} else {
await route.continue()
}
})
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify help center menu appears
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is hidden
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
// Should not show any popups or toasts
await expect(comfyPage.page.locator('.whats-new-popup')).not.toBeVisible()
await expect(
comfyPage.page.locator('.release-notification-toast')
).not.toBeVisible()
})
test('should not make API calls when notifications are disabled', async ({
comfyPage
}) => {
// Disable version update notifications
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
// Track API calls
let apiCallCount = 0
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
apiCallCount++
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
} else {
await route.continue()
}
})
await comfyPage.setup({ mockReleases: false })
// Wait a bit to ensure any potential API calls would have been made
await comfyPage.page.waitForTimeout(1000)
// Verify no API calls were made
expect(apiCallCount).toBe(0)
})
test('should show "What\'s New" section when notifications are enabled', async ({
comfyPage
}) => {
// Enable version update notifications (default behavior)
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', true)
// Mock release API with test data
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'medium',
content: '## New Features\n\n- Added awesome feature',
published_at: new Date().toISOString()
}
])
})
} else {
await route.continue()
}
})
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify help center menu appears
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show the release
await expect(
whatsNewSection.locator('text=Comfy v0.3.44 Release')
).toBeVisible()
})
test('should toggle "What\'s New" section when setting changes', async ({
comfyPage
}) => {
// Mock release API with test data
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 1,
project: 'comfyui',
version: 'v0.3.44',
attention: 'low',
content: '## Bug Fixes\n\n- Fixed minor issue',
published_at: new Date().toISOString()
}
])
})
} else {
await route.continue()
}
})
// Start with notifications enabled
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', true)
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Close help center
await comfyPage.page.click('.help-center-backdrop')
// Disable notifications
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
// Reopen help center
await helpCenterButton.click()
// Verify "What's New?" section is now hidden
await expect(whatsNewSection).not.toBeVisible()
})
test('should handle edge case with empty releases and disabled notifications', async ({
comfyPage
}) => {
// Disable notifications
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
// Mock empty releases
await comfyPage.page.route('**/releases**', async (route) => {
const url = route.request().url()
if (
url.includes('api.comfy.org') ||
url.includes('stagingapi.comfy.org')
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
})
} else {
await route.continue()
}
})
await comfyPage.setup({ mockReleases: false })
// Open help center
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
await helpCenterButton.waitFor({ state: 'visible' })
await helpCenterButton.click()
// Verify help center still works
const helpMenu = comfyPage.page.locator('.help-center-menu')
await expect(helpMenu).toBeVisible()
// Section should be hidden regardless of empty releases
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
})
})

View File

@@ -0,0 +1,82 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph Breadcrumb Title Sync', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Breadcrumb updates when subgraph node title is changed', async ({
comfyPage
}) => {
// Load a workflow with subgraphs
await comfyPage.loadWorkflow('nested-subgraph')
await comfyPage.nextFrame()
// Get the subgraph node by ID (node 10 is the subgraph)
const subgraphNode = await comfyPage.getNodeRefById('10')
// Get node position and double-click on it to enter the subgraph
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2 + 10
}
})
await comfyPage.nextFrame()
// Wait for breadcrumb to appear
await comfyPage.page.waitForSelector('.subgraph-breadcrumb', {
state: 'visible',
timeout: 20000
})
// Get initial breadcrumb text
const breadcrumb = comfyPage.page.locator('.subgraph-breadcrumb')
const initialBreadcrumbText = await breadcrumb.textContent()
// Go back to main graph
await comfyPage.page.keyboard.press('Escape')
// Double-click on the title area of the subgraph node to edit
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10 // Title area is above the node body
},
delay: 5
})
// Wait for title editor to appear
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
// Clear existing text and type new title
await comfyPage.page.keyboard.press('Control+a')
const newTitle = 'Updated Subgraph Title'
await comfyPage.page.keyboard.type(newTitle)
await comfyPage.page.keyboard.press('Enter')
// Wait a frame for the update to complete
await comfyPage.nextFrame()
// Enter the subgraph again
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2
},
delay: 5
})
// Wait for breadcrumb
await comfyPage.page.waitForSelector('.subgraph-breadcrumb')
// Check that breadcrumb now shows the new title
const updatedBreadcrumbText = await breadcrumb.textContent()
expect(updatedBreadcrumbText).toContain(newTitle)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 238 KiB

View File

@@ -0,0 +1,289 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Settings Search functionality', () => {
test.beforeEach(async ({ comfyPage }) => {
// Register test settings to verify hidden/deprecated filtering
await comfyPage.page.evaluate(() => {
window['app'].registerExtension({
name: 'TestSettingsExtension',
settings: [
{
id: 'TestHiddenSetting',
name: 'Test Hidden Setting',
type: 'hidden',
defaultValue: 'hidden_value',
category: ['Test', 'Hidden']
},
{
id: 'TestDeprecatedSetting',
name: 'Test Deprecated Setting',
type: 'text',
defaultValue: 'deprecated_value',
deprecated: true,
category: ['Test', 'Deprecated']
},
{
id: 'TestVisibleSetting',
name: 'Test Visible Setting',
type: 'text',
defaultValue: 'visible_value',
category: ['Test', 'Visible']
}
]
})
})
})
test('can open settings dialog and use search box', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await expect(searchBox).toBeVisible()
// Verify search box has the correct placeholder
await expect(searchBox).toHaveAttribute(
'placeholder',
expect.stringContaining('Search')
)
})
test('search box is functional and accepts input', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Comfy')
// Verify the input was accepted
await expect(searchBox).toHaveValue('Comfy')
})
test('search box clears properly', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('test')
await expect(searchBox).toHaveValue('test')
// Clear the search box
await searchBox.clear()
await expect(searchBox).toHaveValue('')
})
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Check that the sidebar has categories
const categories = comfyPage.page.locator(
'.settings-sidebar .p-listbox-option'
)
expect(await categories.count()).toBeGreaterThan(0)
// Check that at least one category is visible
await expect(categories.first()).toBeVisible()
})
test('can select different categories in sidebar', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Get categories and click on different ones
const categories = comfyPage.page.locator(
'.settings-sidebar .p-listbox-option'
)
const categoryCount = await categories.count()
if (categoryCount > 1) {
// Click on the second category
await categories.nth(1).click()
// Verify the category is selected
await expect(categories.nth(1)).toHaveClass(/p-listbox-option-selected/)
}
})
test('settings content area is visible', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Check that the content area is visible
const contentArea = comfyPage.page.locator('.settings-content')
await expect(contentArea).toBeVisible()
// Check that tab panels are visible
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
await expect(tabPanels).toBeVisible()
})
test('search functionality affects UI state', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
// Type in search box
await searchBox.fill('graph')
await comfyPage.page.waitForTimeout(200) // Wait for debounce
// Verify that the search input is handled
await expect(searchBox).toHaveValue('graph')
})
test('settings dialog can be closed', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Close with escape key
await comfyPage.page.keyboard.press('Escape')
// Verify dialog is closed
await expect(settingsDialog).not.toBeVisible()
})
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Type rapidly in search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('a')
await searchBox.fill('ab')
await searchBox.fill('abc')
await searchBox.fill('abcd')
// Wait for debounce
await comfyPage.page.waitForTimeout(200)
// Verify final value
await expect(searchBox).toHaveValue('abcd')
})
test('search excludes hidden settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await comfyPage.page.waitForTimeout(300) // Wait for debounce
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not hidden setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Hidden Setting')
})
test('search excludes deprecated settings from results', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await comfyPage.page.waitForTimeout(300) // Wait for debounce
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not deprecated setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
})
test('search shows visible settings but excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await comfyPage.page.waitForTimeout(300) // Wait for debounce
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should only show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
// Should not show hidden or deprecated settings
await expect(settingsContent).not.toContainText('Test Hidden Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
})
test('search by setting name excludes hidden and deprecated', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const searchBox = comfyPage.page.locator('.settings-search-box input')
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Search specifically for hidden setting by name
await searchBox.clear()
await searchBox.fill('Hidden')
await comfyPage.page.waitForTimeout(300)
// Should not show the hidden setting even when searching by name
await expect(settingsContent).not.toContainText('Test Hidden Setting')
// Search specifically for deprecated setting by name
await searchBox.clear()
await searchBox.fill('Deprecated')
await comfyPage.page.waitForTimeout(300)
// Should not show the deprecated setting even when searching by name
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
// Search for visible setting by name - should work
await searchBox.clear()
await searchBox.fill('Visible')
await comfyPage.page.waitForTimeout(300)
// Should show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
})
})

View File

@@ -48,7 +48,7 @@ test.describe('Combo text widget', () => {
await comfyPage.page.keyboard.press('r')
// Wait for nodes' widgets to be updated
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(500)
const refreshedComboValues = await getComboValues()
expect(refreshedComboValues).not.toEqual(initialComboValues)

View File

@@ -1,502 +0,0 @@
# Frontend V3 Compatibility Layer Implementation Plan (Import-Based API Versioning)
## Overview
This document outlines an **import-based API versioning** approach for implementing v3 compatibility in the ComfyUI frontend. This approach provides **type-safe API versioning** through import-based version selection, **dual endpoint data fetching**, and **proxy-based data synchronization**. Extensions choose their API version at import time, ensuring compile-time type safety and backward compatibility.
## Key Design Principles
1. **Import-Based Version Selection**: Extensions choose API version through import statements
2. **Type-Safe API Surfaces**: Compile-time type checking for each API version
3. **Dual Endpoint Fetching**: Simultaneous fetching from current and v3 endpoints
4. **Proxy-Based Synchronization**: Bidirectional data sync between API versions
5. **Zero Breaking Changes**: Existing extensions continue to work unchanged
6. **Gradual Migration**: Developers can adopt new versions incrementally
## Architecture Overview
### 1. Import-Based API Version Selection
Extensions import the API version they want to use, getting typed interfaces and guaranteed compatibility:
```typescript
// Legacy extensions (unchanged)
import { app } from '@/scripts/app'
// Version-specific imports
import { app } from '@/scripts/app/v1' // v1.x API
import { app } from '@/scripts/app/v1_2' // v1.2 API
import { app } from '@/scripts/app/v2' // v2.x API
import { app } from '@/scripts/app/latest' // Latest/bleeding edge
import { app } from '@/scripts/app' // Defaults to latest
// Full version-specific imports
import { app, extensionManager, api } from '@/scripts/app/v1_2'
// Extensions get typed, version-specific interfaces
app.registerExtension({
name: 'MyExtension',
beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDefV1_2, app: ComfyAppV1_2) {
// nodeData is guaranteed to be in v1.2 format
// app methods are v1.2 compatible
}
})
```
### 2. Dual Endpoint Data Fetching
The system fetches data from both current and future API endpoints simultaneously:
```typescript
// Fetch from multiple endpoints
const fetchNodeDefinitions = async () => {
const [currentResponse, v3Response] = await Promise.allSettled([
api.get('/object_info'), // Current format
api.get('/v3/object_info') // V3 format (when available)
])
// Store all formats
return {
canonical: mergeToCanonical(currentResponse, v3Response),
v1: transformToV1(currentResponse),
v1_2: transformToV1_2(currentResponse),
v3: v3Response || transformToV3(currentResponse)
}
}
```
### 3. Proxy-Based Data Synchronization
Proxies ensure that changes made through any API version stay synchronized:
```typescript
// Extension modifies node data through v1.2 API
const createV1_2NodeDefProxy = (canonicalNodeDef: ComfyNodeDefLatest) => {
return new Proxy({}, {
get(target, prop) {
// Map v1.2 property access to canonical format
if (prop === 'input') {
return transformLatestToV1_2Input(canonicalNodeDef.inputs)
}
return canonicalNodeDef[mapV1_2PropToLatest(prop)]
},
set(target, prop, value) {
// Map v1.2 property changes back to canonical format
if (prop === 'input') {
canonicalNodeDef.inputs = transformV1_2InputToLatest(value)
notifyDataChange(canonicalNodeDef.name, prop, value)
return true
}
canonicalNodeDef[mapV1_2PropToLatest(prop)] = value
return true
}
})
}
```
## Implementation Architecture
### Phase 1: API Version Infrastructure
**1.1 Version-Specific Entry Points**
```typescript
// src/scripts/app/index.ts (latest/default)
export * from './latest'
// src/scripts/app/v1.ts
export { app as default } from './adapters/v1AppAdapter'
export { extensionManager } from './adapters/v1ExtensionAdapter'
export { api } from './adapters/v1ApiAdapter'
// src/scripts/app/v1_2.ts
export { app as default } from './adapters/v1_2AppAdapter'
export { extensionManager } from './adapters/v1_2ExtensionAdapter'
export { api } from './adapters/v1_2ApiAdapter'
```
**1.2 Version-Specific TypeScript Interfaces**
```typescript
// src/types/versions/v1.ts
export interface ComfyNodeDefV1 {
name: string
input?: {
required?: Record<string, any>
optional?: Record<string, any>
}
output?: string[]
output_is_list?: boolean[]
}
// src/types/versions/v1_2.ts
export interface ComfyNodeDefV1_2 extends ComfyNodeDefV1 {
inputs?: ComfyInputSpecV1_2[]
metadata?: NodeMetadataV1_2
}
// src/types/versions/v3.ts
export interface ComfyNodeDefV3 {
name: string
schema: JsonSchema
inputs: InputSpecV3[]
outputs: OutputSpecV3[]
}
```
### Phase 2: Multi-Version Data Layer
**2.1 Unified Data Store**
```typescript
// src/stores/nodeDefStore.ts
export const useNodeDefStore = defineStore('nodeDef', () => {
const nodeDefinitions = ref<{
canonical: Record<string, ComfyNodeDefLatest>
v1: Record<string, ComfyNodeDefV1>
v1_2: Record<string, ComfyNodeDefV1_2>
v3: Record<string, ComfyNodeDefV3>
}>({
canonical: {},
v1: {},
v1_2: {},
v3: {}
})
const fetchNodeDefinitions = async () => {
const [currentData, v3Data] = await Promise.allSettled([
api.get('/object_info'),
api.get('/v3/object_info')
])
nodeDefinitions.value = transformToAllVersions(currentData, v3Data)
}
// Version-specific getters with reactivity
const getNodeDefsV1 = computed(() => nodeDefinitions.value.v1)
const getNodeDefsV1_2 = computed(() => nodeDefinitions.value.v1_2)
const getNodeDefsV3 = computed(() => nodeDefinitions.value.v3)
return {
nodeDefinitions,
fetchNodeDefinitions,
getNodeDefsV1,
getNodeDefsV1_2,
getNodeDefsV3
}
})
```
**2.2 Data Transformation Pipeline**
```typescript
// src/utils/versionTransforms.ts
export class VersionTransforms {
static transformToAllVersions(currentData: any, v3Data: any) {
const canonical = this.createCanonicalFormat(currentData, v3Data)
return {
canonical,
v1: this.canonicalToV1(canonical),
v1_2: this.canonicalToV1_2(canonical),
v3: v3Data || this.canonicalToV3(canonical)
}
}
static canonicalToV1(canonical: ComfyNodeDefLatest): ComfyNodeDefV1 {
return {
name: canonical.name,
input: {
required: canonical.inputs
?.filter(i => i.required)
.reduce((acc, input) => {
acc[input.name] = input.spec
return acc
}, {} as Record<string, any>),
optional: canonical.inputs
?.filter(i => !i.required)
.reduce((acc, input) => {
acc[input.name] = input.spec
return acc
}, {} as Record<string, any>)
},
output: canonical.outputs?.map(o => o.type),
output_is_list: canonical.outputs?.map(o => o.is_list)
}
}
static canonicalToV1_2(canonical: ComfyNodeDefLatest): ComfyNodeDefV1_2 {
return {
...this.canonicalToV1(canonical),
inputs: canonical.inputs?.map(input => ({
name: input.name,
type: input.type,
required: input.required,
options: input.options
}))
}
}
}
```
### Phase 3: Proxy-Based Synchronization
**3.1 Bidirectional Data Proxies**
```typescript
// src/utils/versionProxies.ts
export class VersionProxies {
private static canonicalStore = new Map<string, ComfyNodeDefLatest>()
private static eventBus = new EventTarget()
static createV1Proxy(nodeId: string): ComfyNodeDefV1 {
const canonical = this.canonicalStore.get(nodeId)
if (!canonical) throw new Error(`Node ${nodeId} not found`)
return new Proxy({} as ComfyNodeDefV1, {
get(target, prop: keyof ComfyNodeDefV1) {
return VersionProxies.transformCanonicalToV1Property(canonical, prop)
},
set(target, prop: keyof ComfyNodeDefV1, value) {
VersionProxies.transformV1PropertyToCanonical(canonical, prop, value)
VersionProxies.notifyChange(nodeId, prop, value)
return true
}
})
}
static createV1_2Proxy(nodeId: string): ComfyNodeDefV1_2 {
const canonical = this.canonicalStore.get(nodeId)
if (!canonical) throw new Error(`Node ${nodeId} not found`)
return new Proxy({} as ComfyNodeDefV1_2, {
get(target, prop: keyof ComfyNodeDefV1_2) {
return VersionProxies.transformCanonicalToV1_2Property(canonical, prop)
},
set(target, prop: keyof ComfyNodeDefV1_2, value) {
VersionProxies.transformV1_2PropertyToCanonical(canonical, prop, value)
VersionProxies.notifyChange(nodeId, prop, value)
return true
}
})
}
private static transformCanonicalToV1Property(canonical: ComfyNodeDefLatest, prop: keyof ComfyNodeDefV1) {
switch (prop) {
case 'input':
return {
required: canonical.inputs?.filter(i => i.required).reduce((acc, input) => {
acc[input.name] = input.spec
return acc
}, {} as Record<string, any>),
optional: canonical.inputs?.filter(i => !i.required).reduce((acc, input) => {
acc[input.name] = input.spec
return acc
}, {} as Record<string, any>)
}
case 'output':
return canonical.outputs?.map(o => o.type)
case 'output_is_list':
return canonical.outputs?.map(o => o.is_list)
default:
return canonical[prop as keyof ComfyNodeDefLatest]
}
}
private static transformV1PropertyToCanonical(canonical: ComfyNodeDefLatest, prop: keyof ComfyNodeDefV1, value: any) {
switch (prop) {
case 'input':
canonical.inputs = [
...Object.entries(value.required || {}).map(([name, spec]) => ({
name,
spec,
required: true,
type: this.inferTypeFromSpec(spec)
})),
...Object.entries(value.optional || {}).map(([name, spec]) => ({
name,
spec,
required: false,
type: this.inferTypeFromSpec(spec)
}))
]
break
case 'output':
canonical.outputs = value.map((type: string, index: number) => ({
type,
is_list: canonical.outputs?.[index]?.is_list || false
}))
break
case 'output_is_list':
canonical.outputs = canonical.outputs?.map((output, index) => ({
...output,
is_list: value[index] || false
}))
break
default:
(canonical as any)[prop] = value
}
}
private static notifyChange(nodeId: string, prop: string, value: any) {
this.eventBus.dispatchEvent(new CustomEvent('nodedef-changed', {
detail: { nodeId, prop, value }
}))
}
}
```
### Phase 4: Extension System Integration
**4.1 Version-Aware Extension Service**
```typescript
// src/services/extensionService.ts
export const useExtensionService = () => {
const extensionsByVersion = new Map<string, ComfyExtension[]>()
const registerExtension = (extension: ComfyExtension, apiVersion: string = 'latest') => {
extension.apiVersion = apiVersion
if (!extensionsByVersion.has(apiVersion)) {
extensionsByVersion.set(apiVersion, [])
}
extensionsByVersion.get(apiVersion)!.push(extension)
}
const invokeExtensionsForAllVersions = async (hook: string, canonicalArgs: any[]) => {
const promises = []
for (const [version, extensions] of extensionsByVersion) {
const versionPromise = invokeExtensionsForVersion(version, hook, canonicalArgs)
promises.push(versionPromise)
}
await Promise.all(promises)
}
const invokeExtensionsForVersion = async (version: string, hook: string, canonicalArgs: any[]) => {
const extensions = extensionsByVersion.get(version) || []
for (const extension of extensions) {
if (extension[hook]) {
const transformedArgs = transformArgsForVersion(version, canonicalArgs)
await extension[hook](...transformedArgs)
}
}
}
const transformArgsForVersion = (version: string, args: any[]) => {
return args.map(arg => {
if (arg && typeof arg === 'object' && arg.name) {
// This is likely a node definition
switch (version) {
case 'v1':
return VersionProxies.createV1Proxy(arg.name)
case 'v1_2':
return VersionProxies.createV1_2Proxy(arg.name)
case 'v3':
return VersionProxies.createV3Proxy(arg.name)
default:
return arg
}
}
return arg
})
}
return {
registerExtension,
invokeExtensionsForAllVersions,
invokeExtensionsForVersion
}
}
```
**4.2 Version-Specific App Adapters**
```typescript
// src/scripts/app/adapters/v1AppAdapter.ts
export class V1AppAdapter {
constructor(private canonicalApp: ComfyApp) {}
registerExtension(extension: ComfyExtensionV1) {
const wrappedExtension = {
...extension,
apiVersion: 'v1',
beforeRegisterNodeDef: (nodeType, nodeData, app) => {
const v1NodeData = VersionProxies.createV1Proxy(nodeData.name)
return extension.beforeRegisterNodeDef?.(nodeType, v1NodeData, this)
},
nodeCreated: (node, app) => {
return extension.nodeCreated?.(node, this)
}
}
this.canonicalApp.registerExtension(wrappedExtension)
}
// Implement other ComfyApp methods with v1 compatibility
}
// src/scripts/app/adapters/v1_2AppAdapter.ts
export class V1_2AppAdapter {
constructor(private canonicalApp: ComfyApp) {}
registerExtension(extension: ComfyExtensionV1_2) {
const wrappedExtension = {
...extension,
apiVersion: 'v1_2',
beforeRegisterNodeDef: (nodeType, nodeData, app) => {
const v1_2NodeData = VersionProxies.createV1_2Proxy(nodeData.name)
return extension.beforeRegisterNodeDef?.(nodeType, v1_2NodeData, this)
}
}
this.canonicalApp.registerExtension(wrappedExtension)
}
}
```
## Migration Strategy
### Gradual Migration Path
Extensions can migrate incrementally:
```typescript
// Phase 1: No changes (works with latest)
import { app } from '@/scripts/app'
// Phase 2: Explicit version (better compatibility)
import { app } from '@/scripts/app/v1_2'
// Phase 3: Use newer APIs when ready
import { app } from '@/scripts/app/latest'
```
### Development Tools
```typescript
// Enhanced debugging for version compatibility
ComfyUI.debugExtensions.showVersionMatrix() // Shows which extensions use which API versions
ComfyUI.debugExtensions.testVersionCompatibility() // Tests extension against all API versions
ComfyUI.debugExtensions.validateDataSync() // Validates proxy synchronization
```
## Benefits
1. **Type Safety**: Compile-time type checking for each API version
2. **Zero Breaking Changes**: Existing extensions work unchanged
3. **Bidirectional Sync**: Changes through any API version stay synchronized
4. **Future Proof**: Easy to add new API versions
5. **Performance**: No runtime version detection overhead
6. **Developer Experience**: Clear, typed interfaces for each version
## Implementation Timeline
- **Phase 1**: API Version Infrastructure (3-4 days)
- **Phase 2**: Multi-Version Data Layer (2-3 days)
- **Phase 3**: Proxy-Based Synchronization (2-3 days)
- **Phase 4**: Extension System Integration (2-3 days)
- **Phase 5**: Migration Tools & Documentation (1-2 days)
**Total Estimated Time**: 10-15 days
This approach provides a solid foundation for API versioning that scales with ComfyUI's growth while maintaining backward compatibility and providing a smooth migration path for extension developers.

View File

@@ -1,134 +0,0 @@
# ComfyUI Frontend V3 Compatibility - Implementation Summary
## Core Concept
**Import-based API versioning** with proxy-synchronized data layers. Extensions choose their API version through imports, getting typed interfaces and guaranteed backward compatibility.
## Architecture Overview
### 1. Version-Specific Imports
```typescript
// Legacy (unchanged)
import { app } from '@/scripts/app'
// Version-specific
import { app } from '@/scripts/app/v1' // v1.x API
import { app } from '@/scripts/app/v1_2' // v1.2 API
import { app } from '@/scripts/app/v3' // v3.x API
import { app } from '@/scripts/app/latest' // Latest
// Typed interfaces per version
app.registerExtension({
beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDefV1_2, app: ComfyAppV1_2) {
// nodeData guaranteed to be v1.2 format
}
})
```
### 2. Dual Endpoint Data Fetching
```typescript
// Simultaneous fetching from multiple endpoints
const [currentData, v3Data] = await Promise.allSettled([
api.get('/object_info'), // Current format
api.get('/v3/object_info') // V3 format
])
// All versions stored and transformed
nodeDefinitions.value = {
canonical: mergeToCanonical(currentData, v3Data),
v1: transformToV1(currentData),
v1_2: transformToV1_2(currentData),
v3: v3Data || transformToV3(currentData)
}
```
### 3. Proxy-Based Synchronization
```typescript
// Bidirectional data sync through proxies
const createV1_2Proxy = (canonical: ComfyNodeDefLatest) => {
return new Proxy({}, {
get(target, prop) {
return transformCanonicalToV1_2(canonical, prop)
},
set(target, prop, value) {
transformV1_2ToCanonical(canonical, prop, value)
notifyChange(canonical.name, prop, value)
return true
}
})
}
```
## Implementation Structure
### Phase 1: API Infrastructure (3-4 days)
- **Entry Points**: `src/scripts/app/v1.ts`, `src/scripts/app/v1_2.ts`, etc.
- **Type Definitions**: `src/types/versions/` with version-specific interfaces
- **Adapters**: `src/scripts/app/adapters/` for API compatibility layers
### Phase 2: Data Layer (2-3 days)
- **Multi-Version Store**: Single store with all format versions
- **Transform Pipeline**: `src/utils/versionTransforms.ts` for format conversion
- **Reactive Getters**: Version-specific computed properties
### Phase 3: Proxy System (2-3 days)
- **Bidirectional Proxies**: `src/utils/versionProxies.ts`
- **Change Notification**: Event system for data sync
- **Type Safety**: Proper typing for proxy objects
### Phase 4: Extension Integration (2-3 days)
- **Version-Aware Service**: Extensions grouped by API version
- **Hook Invocation**: Transform args per version before calling
- **App Adapters**: Version-specific app instances
## Key Benefits
- **Type Safety**: Compile-time checking for each API version
- **Zero Breaking Changes**: Existing extensions work unchanged
- **Bidirectional Sync**: Changes through any API stay synchronized
- **Performance**: No runtime version detection overhead
- **Future Proof**: Easy to add new API versions
- **Developer Experience**: Clear, typed interfaces
## Migration Path
```typescript
// Step 1: No changes (works with latest)
import { app } from '@/scripts/app'
// Step 2: Explicit version (better compatibility)
import { app } from '@/scripts/app/v1_2'
// Step 3: Use newer APIs when ready
import { app } from '@/scripts/app/latest'
```
## Example Usage
```typescript
// v1.2 Extension
import { app } from '@/scripts/app/v1_2'
app.registerExtension({
name: 'MyExtension',
beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDefV1_2, app) {
// nodeData.input.required - v1 format
// nodeData.inputs - v1.2 format
if (nodeData.inputs) {
// Use v1.2 features
} else {
// Fallback to v1 format
}
}
})
```
## Implementation Timeline
- **Phase 1**: API Version Infrastructure (3-4 days)
- **Phase 2**: Multi-Version Data Layer (2-3 days)
- **Phase 3**: Proxy-Based Synchronization (2-3 days)
- **Phase 4**: Extension System Integration (2-3 days)
- **Phase 5**: Migration Tools & Documentation (1-2 days)
**Total**: 10-15 days
This approach provides type-safe API versioning that scales with ComfyUI's growth while maintaining complete backward compatibility and smooth migration paths for extension developers.

180
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.0-1",
"version": "1.24.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.0-1",
"version": "1.24.1",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.16.5",
"@comfyorg/litegraph": "^0.16.13",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -40,6 +40,7 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",
@@ -62,6 +63,7 @@
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@types/semver": "^7.7.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.6",
@@ -557,6 +559,16 @@
"url": "https://opencollective.com/babel"
}
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/generator": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
@@ -601,6 +613,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz",
@@ -622,6 +644,16 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
@@ -949,10 +981,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.16.5",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.5.tgz",
"integrity": "sha512-XFjlMQj6+ONbgl9dzmFP/g8Jfohzbdgxu/j9vld0bE4DDO59v8JCPrmE32ugM/18swxuoEk6iZNWKf2PGs5X2A==",
"license": "MIT"
"version": "0.16.13",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.13.tgz",
"integrity": "sha512-Y65YDyX/X/gqxtEa3IS5jb/1UUETCZXnrxM7YwSTIdzq3SHqwnDNstVBvhe+MtQUYRYqURzlg6sSrCO/BoKwkQ=="
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
@@ -4523,6 +4554,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/semver": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz",
"integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz",
@@ -4754,19 +4792,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.0.tgz",
@@ -6537,19 +6562,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/conf/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/confbox": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz",
@@ -7449,19 +7461,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/editorconfig/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -7852,19 +7851,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-vue/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-plugin-vue/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
@@ -10726,19 +10712,6 @@
"node": ">=14"
}
},
"node_modules/langsmith/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/latest-version": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz",
@@ -12688,19 +12661,6 @@
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
"dev": true
},
"node_modules/package-json/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/package-manager-detector": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.0.tgz",
@@ -14343,12 +14303,15 @@
"dev": true
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
@@ -16207,19 +16170,6 @@
"url": "https://github.com/yeoman/update-notifier?sponsor=1"
}
},
"node_modules/update-notifier/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -17039,19 +16989,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vue-i18n": {
"version": "9.14.3",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.3.tgz",
@@ -17104,19 +17041,6 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vue-tsc/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vuefire": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/vuefire/-/vuefire-3.2.1.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.24.0-1",
"version": "1.24.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -40,6 +40,7 @@
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@types/semver": "^7.7.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.6",
@@ -77,7 +78,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.16.5",
"@comfyorg/litegraph": "^0.16.13",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -105,6 +106,7 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",

View File

@@ -17,7 +17,15 @@ const IGNORE_PATTERNS = [
/^templateWorkflows\./, // Template workflows are loaded dynamically
/^dataTypes\./, // Data types might be referenced dynamically
/^contextMenu\./, // Context menu items might be dynamic
/^color\./ // Color names might be used dynamically
/^color\./, // Color names might be used dynamically
// Auto-generated categories from collect-i18n-general.ts
/^menuLabels\./, // Menu labels generated from command labels
/^settingsCategories\./, // Settings categories generated from setting definitions
/^serverConfigItems\./, // Server config items generated from SERVER_CONFIG_ITEMS
/^serverConfigCategories\./, // Server config categories generated from config categories
/^nodeCategories\./, // Node categories generated from node definitions
// Setting option values that are dynamically generated
/\.options\./ // All setting options are rendered dynamically
]
// Get list of staged locale files
@@ -97,17 +105,21 @@ function shouldIgnoreKey(key: string): boolean {
// Search for key usage in source files
function isKeyUsed(key: string, sourceFiles: string[]): boolean {
// Escape special regex characters
const escapeRegex = (str: string) =>
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const escapedKey = escapeRegex(key)
const lastPart = key.split('.').pop()
const escapedLastPart = lastPart ? escapeRegex(lastPart) : ''
// Common patterns for i18n key usage
const patterns = [
// Direct usage: $t('key'), t('key'), i18n.t('key')
new RegExp(`[t$]\\s*\\(\\s*['"\`]${key}['"\`]`, 'g'),
new RegExp(`[t$]\\s*\\(\\s*['"\`]${escapedKey}['"\`]`, 'g'),
// With namespace: $t('g.key'), t('namespace.key')
new RegExp(
`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${key.split('.').pop()}['"\`]`,
'g'
),
new RegExp(`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${escapedLastPart}['"\`]`, 'g'),
// Dynamic keys might reference parts of the key
new RegExp(`['"\`]${key}['"\`]`, 'g')
new RegExp(`['"\`]${escapedKey}['"\`]`, 'g')
]
for (const file of sourceFiles) {
@@ -154,7 +166,7 @@ async function checkNewUnusedKeys() {
// Report results
if (unusedNewKeys.length > 0) {
console.log('\n Found unused NEW i18n keys:\n')
console.log('\n⚠️ Warning: Found unused NEW i18n keys:\n')
for (const key of unusedNewKeys.sort()) {
console.log(` - ${key}`)
@@ -164,9 +176,10 @@ async function checkNewUnusedKeys() {
console.log(
'\nThese keys were added but are not used anywhere in the codebase.'
)
console.log('Please either use them or remove them before committing.')
console.log('Consider using them or removing them in a future update.')
process.exit(1)
// Changed from process.exit(1) to process.exit(0) for warning only
process.exit(0)
} else {
// Silent success - no output needed
}

View File

@@ -15,12 +15,14 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
@@ -47,5 +49,9 @@ onMounted(() => {
if (isElectron()) {
document.addEventListener('contextmenu', showContextMenu)
}
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()
})
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div
class="inline-flex items-center justify-center"
:style="{ width: size + 'px', height: size + 'px' }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 14 14"
fill="none"
class="animate-spin"
:style="{ animationDuration: duration }"
>
<g clip-path="url(#clip0_776_9582)">
<!-- Top dot -->
<path
class="dot-animation"
style="animation-delay: 0s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M7 2.21053C7.61042 2.21053 8.10526 1.71568 8.10526 1.10526C8.10526 0.494843 7.61042 0 7 0C6.38958 0 5.89474 0.494843 5.89474 1.10526C5.89474 1.71568 6.38958 2.21053 7 2.21053Z"
:fill="color"
/>
<!-- Left dot -->
<path
class="dot-animation"
style="animation-delay: 0.25s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.21053 7C2.21053 7.61042 1.71568 8.10526 1.10526 8.10526C0.494843 8.10526 0 7.61042 0 7C0 6.38958 0.494843 5.89474 1.10526 5.89474C1.71568 5.89474 2.21053 6.38958 2.21053 7Z"
:fill="color"
/>
<!-- Right dot -->
<path
class="dot-animation"
style="animation-delay: 0.5s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 7C14 7.61042 13.5052 8.10526 12.8947 8.10526C12.2843 8.10526 11.7895 7.61042 11.7895 7C11.7895 6.38958 12.2843 5.89474 12.8947 5.89474C13.5052 5.89474 14 6.38958 14 7Z"
:fill="color"
/>
<!-- Bottom dot -->
<path
class="dot-animation"
style="animation-delay: 0.75s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.10526 12.8947C8.10526 13.5052 7.61041 14 6.99999 14C6.38957 14 5.89473 13.5052 5.89473 12.8947C5.89473 12.2843 6.38957 11.7895 6.99999 11.7895C7.61041 11.7895 8.10526 12.2843 8.10526 12.8947Z"
:fill="color"
/>
<!-- Top-left dot -->
<path
class="dot-animation"
style="animation-delay: 0.125s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.05039 3.61349C2.48203 4.04513 3.18184 4.04513 3.61347 3.61349C4.0451 3.18186 4.0451 2.48205 3.61347 2.05042C3.18184 1.61878 2.48203 1.61878 2.05039 2.05042C1.61876 2.48205 1.61876 3.18186 2.05039 3.61349Z"
:fill="color"
/>
<!-- Bottom-right dot -->
<path
class="dot-animation"
style="animation-delay: 0.625s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9496 11.9496C11.518 12.3812 10.8182 12.3812 10.3865 11.9496C9.9549 11.5179 9.9549 10.8181 10.3865 10.3865C10.8182 9.95485 11.518 9.95485 11.9496 10.3865C12.3812 10.8181 12.3812 11.5179 11.9496 11.9496Z"
:fill="color"
/>
<!-- Bottom-left dot -->
<path
class="dot-animation"
style="animation-delay: 0.875s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.05039 11.9496C2.48203 12.3812 3.18184 12.3812 3.61347 11.9496C4.0451 11.5179 4.0451 10.8181 3.61347 10.3865C3.18184 9.95485 2.48203 9.95485 2.05039 10.3865C1.61876 10.8181 1.61876 11.5179 2.05039 11.9496Z"
:fill="color"
/>
<!-- Top-right dot -->
<path
class="dot-animation"
style="animation-delay: 0.375s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9496 3.61349C11.518 4.04513 10.8182 4.04513 10.3865 3.61349C9.9549 3.18186 9.9549 2.48205 10.3865 2.05042C10.8182 1.61878 11.518 1.61878 11.9496 2.05042C12.3812 2.48205 12.3812 3.18186 11.9496 3.61349Z"
:fill="color"
/>
</g>
<defs>
<clipPath id="clip0_776_9582">
<rect width="14" height="14" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const { size = 24, duration = '2s' } = defineProps<{
size?: number
duration?: string
}>()
const colorPaletteStore = useColorPaletteStore()
const color = computed(() =>
colorPaletteStore.completedActivePalette.light_theme ? '#2C2B30' : '#D4D4D4'
)
</script>
<style scoped>
.dot-animation {
animation: dot-pulse 1s ease-in-out infinite;
}
@keyframes dot-pulse {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
</style>

View File

@@ -27,7 +27,7 @@
/>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" />
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</Dialog>
</template>

View File

@@ -31,7 +31,7 @@
</div>
</template>
</ListBox>
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<div v-if="!isLegacyManager" class="flex justify-end py-3">
<PackInstallButton
:disabled="isLoading || !!error || missingNodePacks.length === 0"
:node-packs="missingNodePacks"
@@ -45,14 +45,14 @@
<script setup lang="ts">
import Button from 'primevue/button'
import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -60,22 +60,11 @@ const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const aboutPanelStore = useAboutPanelStore()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const isLegacyManager = ref(false)
const uniqueNodes = computed(() => {
const seenTypes = new Set()
@@ -103,6 +92,13 @@ const openManager = () => {
initialTab: ManagerTab.Missing
})
}
onMounted(async () => {
const isLegacyResponse = await useComfyManagerService().isLegacyManagerUI()
if (isLegacyResponse?.is_legacy_manager_ui) {
isLegacyManager.value = true
}
})
</script>
<style scoped>

View File

@@ -30,11 +30,20 @@ const defaultMockTaskLogs = [
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs]
taskLogs: [...defaultMockTaskLogs],
succeededTasksLogs: [...defaultMockTaskLogs],
failedTasksLogs: [...defaultMockTaskLogs],
managerQueue: { historyCount: 2 },
isLoading: false
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
collapse: mockCollapse
activeTabIndex: 0,
getActiveTabIndex: vi.fn(() => 0),
setActiveTabIndex: vi.fn(),
toggle: vi.fn(),
collapse: mockCollapse,
expand: vi.fn()
}))
}))

View File

@@ -18,7 +18,7 @@
'max-h-0': !isExpanded
}"
>
<div v-for="(panel, index) in taskPanels" :key="index">
<div v-for="(log, index) in focusedLogs" :key="index">
<Panel
:expanded="collapsedPanels[index] || false"
toggleable
@@ -27,7 +27,7 @@
<template #header>
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ panel.taskName }}</span>
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isInProgress(index)
@@ -52,24 +52,24 @@
</template>
<div
:ref="
index === taskPanels.length - 1
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
'h-64': index !== focusedLogs.length - 1,
'flex-grow': index === focusedLogs.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(log, logIndex) in panel.logs"
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-neutral-400 dark-theme:text-muted"
>
<pre class="whitespace-pre-wrap break-words">{{ log }}</pre>
<pre class="whitespace-pre-wrap break-words">{{ logLine }}</pre>
</div>
</div>
</div>
@@ -90,17 +90,23 @@ import {
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const { taskLogs } = useComfyManagerStore()
const comfyManagerStore = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const managerStore = useComfyManagerStore()
const isInProgress = (index: number) =>
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
index === comfyManagerStore.managerQueue.historyCount - 1 &&
comfyManagerStore.isLoading
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const focusedLogs = computed(() => {
if (progressDialogContent.getActiveTabIndex() === 0) {
return comfyManagerStore.succeededTasksLogs
}
return comfyManagerStore.failedTasksLogs
})
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
collapsedPanels.value[index] = !collapsedPanels.value[index]
@@ -115,7 +121,7 @@ const { y: scrollY } = useScroll(sectionsContainerRef, {
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false

View File

@@ -26,6 +26,27 @@
}"
>
<div class="px-6 flex flex-col h-full">
<!-- Conflict Warning Banner -->
<div
v-if="shouldShowManagerBanner"
class="bg-yellow-600 bg-opacity-20 border border-yellow-400 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6"
>
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
<div class="flex flex-col gap-2">
<p class="text-sm font-bold m-0">
{{ $t('manager.conflicts.warningBanner.title') }}
</p>
<p class="text-xs m-0">
{{ $t('manager.conflicts.warningBanner.message') }}
</p>
<p
class="text-sm font-bold m-0 cursor-pointer"
@click="onClickWarningLink"
>
{{ $t('manager.conflicts.warningBanner.button') }}
</p>
</div>
</div>
<RegistrySearchBar
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
@@ -34,6 +55,7 @@
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
:is-update-available-tab="isUpdateAvailableTab"
/>
<div class="flex-1 overflow-auto">
<div
@@ -69,7 +91,9 @@
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="(event) => selectNodePack(item, event)"
@click.stop="
(event: MouseEvent) => selectNodePack(item, event)
"
/>
</template>
</VirtualGrid>
@@ -101,7 +125,8 @@ import {
onMounted,
onUnmounted,
ref,
watch
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -119,6 +144,7 @@ import { useManagerStatePersistence } from '@/composables/manager/useManagerStat
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useConflictBannerState } from '@/composables/useConflictBannerState'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
@@ -133,6 +159,7 @@ const { initialTab } = defineProps<{
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const conflictBannerState = useConflictBannerState()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
@@ -149,6 +176,9 @@ const {
toggle: toggleSideNav
} = useResponsiveCollapse()
// Use conflict banner state from composable
const { shouldShowManagerBanner, markConflictsAsSeen } = conflictBannerState
const tabs = ref<TabItem[]>([
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
@@ -312,6 +342,13 @@ watch([isAllTab, searchResults], () => {
displayPacks.value = searchResults.value
})
const onClickWarningLink = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const onResultsChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
@@ -440,6 +477,7 @@ whenever(selectedNodePack, async () => {
if (hasMultipleSelections.value) return
// Only fetch if we haven't already for this pack
if (lastFetchedPackId.value === pack.id) return
const data = await getPackById.call(pack.id)
// If selected node hasn't changed since request, merge registry & Algolia data
if (data?.id === pack.id) {
@@ -472,6 +510,14 @@ watch([searchQuery, selectedTab], () => {
}
})
// Automatically mark conflicts as seen when banner is displayed
// This ensures red dots disappear and banner is dismissed once user sees it
watchEffect(() => {
if (shouldShowManagerBanner.value) {
markConflictsAsSeen()
}
})
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,

View File

@@ -4,6 +4,16 @@
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
<Tag
v-tooltip.left="$t('manager.legacyManagerUIDescription')"
severity="info"
icon="pi pi-info-circle"
:value="$t('manager.legacyManagerUI')"
class="cursor-help"
:pt="{
root: { class: 'text-xs' }
}"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,163 @@
<template>
<div class="w-[552px] max-h-[246px] flex flex-col">
<ContentDivider :width="1" />
<div class="px-4 py-6 w-full h-full flex flex-col gap-2">
<!-- Description -->
<!-- <div>
<p class="text-sm leading-4 text-gray-100 m-0 mb-4">
{{ $t('manager.conflicts.description') }}
<br /><br />
{{ $t('manager.conflicts.info') }}
</p>
</div> -->
<!-- Conflict List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleConflictsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ allConflictDetails.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.conflicts') }}</span
>
</div>
<div>
<Button
:icon="
conflictsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Conflicts list -->
<div
v-if="conflictsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span
class="text-xs text-neutral-600 dark-theme:text-neutral-300"
>{{ getConflictMessage(conflict, t) }}</span
>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Extension List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleExtensionsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ conflictData.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.extensionAtRisk') }}</span
>
</div>
<div>
<Button
:icon="
extensionsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Extension list -->
<div
v-if="extensionsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ conflictResult.package_name }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
</div>
<ContentDivider :width="1" />
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
interface Props {
conflicts?: ConflictDetectionResult[]
conflictedPackages?: ConflictDetectionResult[]
}
const props = withDefaults(defineProps<Props>(), {
conflicts: () => [],
conflictedPackages: () => []
})
const { t } = useI18n()
const conflictsExpanded = ref<boolean>(false)
const extensionsExpanded = ref<boolean>(false)
// Use conflictedPackages if provided, otherwise fallback to conflicts
const conflictData = computed(() =>
props.conflictedPackages.length > 0
? props.conflictedPackages
: props.conflicts
)
const allConflictDetails = computed(() =>
conflictData.value.flatMap((result) => result.conflicts)
)
const toggleConflictsPanel = () => {
conflictsExpanded.value = !conflictsExpanded.value
extensionsExpanded.value = false
}
const toggleExtensionsPanel = () => {
extensionsExpanded.value = !extensionsExpanded.value
conflictsExpanded.value = false
}
</script>
<style scoped>
.conflict-list-item:hover {
background-color: rgba(0, 122, 255, 0.2);
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="flex items-center justify-between w-full px-3 py-4">
<div class="w-full flex items-center justify-between gap-2 pr-1">
<Button
:label="$t('manager.conflicts.conflictInfoTitle')"
text
severity="secondary"
size="small"
icon="pi pi-info-circle"
:pt="{
label: { class: 'text-sm' }
}"
@click="handleConflictInfoClick"
/>
<Button
v-if="props.buttonText"
:label="props.buttonText"
severity="secondary"
size="small"
@click="handleButtonClick"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useDialogStore } from '@/stores/dialogStore'
interface Props {
buttonText?: string
onButtonClick?: () => void
}
const props = withDefaults(defineProps<Props>(), {
buttonText: undefined,
onButtonClick: undefined
})
const dialogStore = useDialogStore()
const handleConflictInfoClick = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const handleButtonClick = () => {
// Close the conflict dialog
dialogStore.closeDialog({ key: 'global-node-conflict' })
// Execute the custom button action if provided
if (props.onButtonClick) {
props.onButtonClick()
}
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="h-12 flex items-center justify-between w-full pl-6">
<div class="flex items-center gap-2">
<!-- Warning Icon -->
<i class="pi pi-exclamation-triangle text-lg"></i>
<!-- Title -->
<p class="text-base font-bold">
{{ $t('manager.conflicts.title') }}
</p>
</div>
</div>
</template>

View File

@@ -32,8 +32,9 @@ type StatusProps = {
severity: MessageSeverity
}
const { statusType } = defineProps<{
const { statusType, hasCompatibilityIssues } = defineProps<{
statusType: Status
hasCompatibilityIssues?: boolean
}>()
const statusPropsMap: Record<Status, StatusProps> = {
@@ -71,10 +72,14 @@ const statusPropsMap: Record<Status, StatusProps> = {
}
}
const statusLabel = computed(
() => statusPropsMap[statusType]?.label || 'unknown'
const statusLabel = computed(() =>
hasCompatibilityIssues
? 'conflicting'
: statusPropsMap[statusType]?.label || 'unknown'
)
const statusSeverity = computed(
() => statusPropsMap[statusType]?.severity || 'secondary'
const statusSeverity = computed(() =>
hasCompatibilityIssues
? 'error'
: statusPropsMap[statusType]?.severity || 'secondary'
)
</script>

View File

@@ -6,11 +6,18 @@ import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionBadge from './PackVersionBadge.vue'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
@@ -120,7 +127,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
expect(badge.find('span').text()).toBe('nightly')
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
@@ -134,7 +141,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
expect(badge.find('span').text()).toBe('nightly')
})
it('toggles the popover when button is clicked', async () => {

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
aria-haspopup="true"
role="button"
tabindex="0"
@@ -12,8 +12,7 @@
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600"
style="font-size: 8px"
class="pi pi-arrow-circle-up text-blue-600 text-xs"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right" style="font-size: 8px" />
@@ -22,7 +21,7 @@
<Popover
ref="popoverRef"
:pt="{
content: { class: 'px-0' }
content: { class: 'p-0 shadow-lg' }
}"
>
<PackVersionSelectorPopover
@@ -42,8 +41,7 @@ import { computed, ref, watch } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
@@ -64,11 +62,11 @@ const popoverRef = ref()
const managerStore = useComfyManagerStore()
const installedVersion = computed(() => {
if (!nodePack.id) return SelectedVersion.NIGHTLY
if (!nodePack.id) return 'nightly'
const version =
managerStore.installedPacks[nodePack.id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
'nightly'
// If Git hash, truncate to 7 characters
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)

View File

@@ -3,18 +3,32 @@ import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Listbox from 'primevue/listbox'
import Select from 'primevue/select'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
// SelectedVersion is now using direct strings instead of enum
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Default mock versions for reference
const defaultMockVersions = [
{ version: '1.0.0', createdAt: '2023-01-01' },
{
version: '1.0.0',
createdAt: '2023-01-01',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
]
@@ -22,13 +36,24 @@ const defaultMockVersions = [
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' },
repository: 'https://github.com/user/repo'
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
repository: 'https://github.com/user/repo',
has_registry_data: true
}
// Create mock functions
const mockGetPackVersions = vi.fn()
const mockInstallPack = vi.fn().mockResolvedValue(undefined)
const mockCheckVersionCompatibility = vi.fn()
// Mock the registry service
vi.mock('@/services/comfyRegistryService', () => ({
@@ -49,6 +74,13 @@ vi.mock('@/stores/comfyManagerStore', () => ({
}))
}))
// Mock the conflict detection composable
vi.mock('@/composables/useConflictDetection', () => ({
useConflictDetection: vi.fn(() => ({
checkVersionCompatibility: mockCheckVersionCompatibility
}))
}))
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
@@ -59,6 +91,9 @@ describe('PackVersionSelectorPopover', () => {
vi.clearAllMocks()
mockGetPackVersions.mockReset()
mockInstallPack.mockReset().mockResolvedValue(undefined)
mockCheckVersionCompatibility
.mockReset()
.mockReturnValue({ hasConflict: false, conflicts: [] })
})
const mountComponent = ({
@@ -78,7 +113,12 @@ describe('PackVersionSelectorPopover', () => {
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox
Listbox,
VerifiedIcon,
Select
},
directives: {
tooltip: Tooltip
}
}
})
@@ -120,14 +160,15 @@ describe('PackVersionSelectorPopover', () => {
const options = listbox.props('options')!
// Check that we have both special options and version options
expect(options.length).toBe(defaultMockVersions.length + 2) // 2 special options + version options
// Latest version (1.0.0) should be excluded from the version list to avoid duplication
expect(options.length).toBe(defaultMockVersions.length + 1) // 2 special options + version options minus 1 duplicate
// Check that special options exist
expect(options.some((o) => o.value === SelectedVersion.NIGHTLY)).toBe(true)
expect(options.some((o) => o.value === SelectedVersion.LATEST)).toBe(true)
expect(options.some((o) => o.value === 'nightly')).toBe(true)
expect(options.some((o) => o.value === 'latest')).toBe(true)
// Check that version options exist
expect(options.some((o) => o.value === '1.0.0')).toBe(true)
// Check that version options exist (excluding latest version 1.0.0)
expect(options.some((o) => o.value === '1.0.0')).toBe(false) // Should be excluded as it's the latest
expect(options.some((o) => o.value === '0.9.0')).toBe(true)
expect(options.some((o) => o.value === '0.8.0')).toBe(true)
})
@@ -304,7 +345,7 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
expect(listbox.props('modelValue')).toBe('nightly')
})
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
@@ -325,7 +366,360 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
expect(listbox.props('modelValue')).toBe('nightly')
})
})
describe('version compatibility checking', () => {
it('shows warning icon for incompatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return conflict for specific version
mockCheckVersionCompatibility.mockImplementation((versionData) => {
if (versionData.supported_os?.includes('linux')) {
return {
hasConflict: true,
conflicts: [
{
type: 'os',
current_value: 'windows',
required_value: 'linux'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['linux'],
supported_accelerators: ['CUDA']
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckVersionCompatibility).toHaveBeenCalled()
// The warning icon should be shown for incompatible versions
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows verified icon for compatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return no conflicts
mockCheckVersionCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckVersionCompatibility).toHaveBeenCalled()
// The verified icon should be shown for compatible versions
// Look for the VerifiedIcon component or SVG elements
const verifiedIcons = wrapper.findAll('svg')
expect(verifiedIcons.length).toBeGreaterThan(0)
})
it('calls checkVersionCompatibility with correct version data', async () => {
// Set up the mock for versions with specific supported data
const versionsWithCompatibility = [
{
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CUDA', 'CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
]
mockGetPackVersions.mockResolvedValueOnce(versionsWithCompatibility)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Trigger compatibility check by accessing getVersionCompatibility
const vm = wrapper.vm as any
vm.getVersionCompatibility('1.0.0')
// Verify that checkVersionCompatibility was called with correct data
// Since 1.0.0 is the latest version, it should use latest_version data
expect(mockCheckVersionCompatibility).toHaveBeenCalledWith({
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
})
})
it('shows version conflict warnings for ComfyUI and frontend versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return version conflicts
mockCheckVersionCompatibility.mockImplementation((versionData) => {
const conflicts = []
if (versionData.supported_comfyui_version) {
conflicts.push({
type: 'comfyui_version',
current_value: '0.5.0',
required_value: versionData.supported_comfyui_version
})
}
if (versionData.supported_comfyui_frontend_version) {
conflicts.push({
type: 'frontend_version',
current_value: '1.0.0',
required_value: versionData.supported_comfyui_frontend_version
})
}
return {
hasConflict: conflicts.length > 0,
conflicts
}
})
const nodePackWithVersionRequirements = {
...mockNodePack,
supported_comfyui_version: '>=1.0.0',
supported_comfyui_frontend_version: '>=2.0.0'
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithVersionRequirements }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckVersionCompatibility).toHaveBeenCalled()
// The warning icon should be shown for version incompatible packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('handles latest and nightly versions using nodePack data', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
...mockNodePack.latest_version,
supported_os: ['windows'], // Match nodePack data for test consistency
supported_accelerators: ['CPU'], // Match nodePack data for test consistency
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
const vm = wrapper.vm as any
// Test latest version
vm.getVersionCompatibility('latest')
expect(mockCheckVersionCompatibility).toHaveBeenCalledWith({
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
})
// Test nightly version
vm.getVersionCompatibility('nightly')
expect(mockCheckVersionCompatibility).toHaveBeenCalledWith({
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: undefined,
is_banned: false,
has_registry_data: false
})
})
it('shows python version conflict warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return python version conflicts
mockCheckVersionCompatibility.mockImplementation((versionData) => {
if (versionData.supported_python_version) {
return {
hasConflict: true,
conflicts: [
{
type: 'python_version',
current_value: '3.8.0',
required_value: versionData.supported_python_version
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const nodePackWithPythonRequirement = {
...mockNodePack,
supported_python_version: '>=3.9.0'
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithPythonRequirement }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckVersionCompatibility).toHaveBeenCalled()
// The warning icon should be shown for python version incompatible packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows banned package warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return banned conflicts
mockCheckVersionCompatibility.mockImplementation((versionData) => {
if (versionData.is_banned === true) {
return {
hasConflict: true,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const bannedNodePack = {
...mockNodePack,
is_banned: true,
latest_version: {
...mockNodePack.latest_version,
is_banned: true
}
}
const wrapper = mountComponent({
props: { nodePack: bannedNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckVersionCompatibility).toHaveBeenCalled()
// Open the dropdown to see the options
const select = wrapper.find('.p-select')
if (!select.exists()) {
// Try alternative selector
const selectButton = wrapper.find('[aria-haspopup="listbox"]')
if (selectButton.exists()) {
await selectButton.trigger('click')
}
} else {
await select.trigger('click')
}
await wrapper.vm.$nextTick()
// The warning icon should be shown for banned packages in the dropdown options
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows security pending warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return security pending conflicts
mockCheckVersionCompatibility.mockImplementation((versionData) => {
if (versionData.has_registry_data === false) {
return {
hasConflict: true,
conflicts: [
{
type: 'security_pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const securityPendingNodePack = {
...mockNodePack,
has_registry_data: false,
latest_version: {
...mockNodePack.latest_version,
has_registry_data: false
}
}
const wrapper = mountComponent({
props: { nodePack: securityPendingNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckVersionCompatibility).toHaveBeenCalled()
// The warning icon should be shown for security pending packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
})
})

View File

@@ -1,8 +1,10 @@
<template>
<div class="w-64 mt-2">
<span class="pl-3 text-muted text-md font-semibold opacity-70">
{{ $t('manager.selectVersion') }}
</span>
<div class="w-64 pt-1">
<div class="py-2">
<span class="pl-3 text-md font-semibold text-neutral-500">
{{ $t('manager.selectVersion') }}
</span>
</div>
<div
v-if="isLoadingVersions || isQueueing"
class="text-center text-muted py-4 flex flex-col items-center"
@@ -23,24 +25,44 @@
v-model="selectedVersion"
option-label="label"
option-value="value"
:options="versionOptions"
:options="processedVersionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none shadow-none"
class="w-full max-h-[50vh] border-none shadow-none rounded-md"
:pt="{
listContainer: { class: 'scrollbar-hide' }
}"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<span>{{ slotProps.option.label }}</span>
<div class="flex items-center gap-2">
<template v-if="slotProps.option.value === 'nightly'">
<div class="w-4"></div>
</template>
<template v-else>
<i
v-if="slotProps.option.hasConflict"
v-tooltip="{
value: slotProps.option.conflictMessage,
showDelay: 300
}"
class="pi pi-exclamation-triangle text-yellow-500"
/>
<VerifiedIcon v-else :size="20" class="relative right-0.5" />
</template>
<span>{{ slotProps.option.label }}</span>
</div>
<i
v-if="selectedVersion === slotProps.option.value"
v-if="slotProps.option.isSelected"
class="pi pi-check text-highlight"
/>
</div>
</template>
</Listbox>
<ContentDivider class="my-2" />
<div class="flex justify-end gap-2 p-1 px-3">
<div class="flex justify-end gap-2 py-1 px-3">
<Button
text
class="text-sm"
severity="secondary"
:label="$t('g.cancel')"
:disabled="isQueueing"
@@ -49,7 +71,7 @@
<Button
severity="secondary"
:label="$t('g.install')"
class="py-3 px-4 dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
class="py-2.5 px-4 text-sm dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
:disabled="isQueueing"
@click="handleSubmit"
/>
@@ -62,19 +84,18 @@ import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
import { isSemVer } from '@/utils/formatUtil'
const { nodePack } = defineProps<{
@@ -89,27 +110,30 @@ const emit = defineEmits<{
const { t } = useI18n()
const registryService = useComfyRegistryService()
const managerStore = useComfyManagerStore()
const { checkNodeCompatibility } = useConflictDetection()
const isQueueing = ref(false)
const selectedVersion = ref<string>(SelectedVersion.LATEST)
const selectedVersion = ref<string>('latest')
onMounted(() => {
const initialVersion = getInitialSelectedVersion() ?? SelectedVersion.LATEST
const initialVersion = getInitialSelectedVersion() ?? 'latest'
selectedVersion.value =
// Use NIGHTLY when version is a Git hash
isSemVer(initialVersion) ? initialVersion : SelectedVersion.NIGHTLY
isSemVer(initialVersion) ? initialVersion : 'nightly'
})
const getInitialSelectedVersion = () => {
if (!nodePack.id) return
// If unclaimed, set selected version to nightly
if (nodePack.publisher?.name === 'Unclaimed') return SelectedVersion.NIGHTLY
// If node pack is installed, set selected version to the installed version
if (managerStore.isPackInstalled(nodePack.id))
return managerStore.getInstalledPackVersion(nodePack.id)
// If unclaimed, set selected version to nightly
if (nodePack.publisher?.name === 'Unclaimed')
return 'nightly' as ManagerComponents['schemas']['SelectedVersion']
// If node pack is not installed, set selected version to latest
return nodePack.latest_version?.version
}
@@ -126,6 +150,9 @@ const versionOptions = ref<
}[]
>([])
// Store fetched versions with their full data
const fetchedVersions = ref<components['schemas']['NodeVersion'][]>([])
const isLoadingVersions = ref(false)
const onNodePackChange = async () => {
@@ -133,26 +160,35 @@ const onNodePackChange = async () => {
// Fetch versions from the registry
const versions = await fetchVersions()
fetchedVersions.value = versions
// Get latest version number to exclude from the list
const latestVersionNumber = nodePack.latest_version?.version
const availableVersionOptions = versions
.map((version) => ({
value: version.version ?? '',
label: version.version ?? ''
}))
.filter((option) => option.value)
.filter((option) => option.value && option.value !== latestVersionNumber) // Exclude latest version from the list
// Add Latest option with actual version number
const latestLabel = latestVersionNumber
? `${t('manager.latestVersion')} (${latestVersionNumber})`
: t('manager.latestVersion')
// Add Latest option
const defaultVersions = [
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
value: 'latest' as ManagerComponents['schemas']['SelectedVersion'],
label: latestLabel
}
]
// Add Nightly option if there is a non-empty `repository` field
if (nodePack.repository?.length) {
defaultVersions.push({
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
value: 'nightly' as ManagerComponents['schemas']['SelectedVersion'],
label: t('manager.nightlyVersion') // Keep as just "nightly" - no version number
})
}
@@ -172,16 +208,95 @@ whenever(
const handleSubmit = async () => {
isQueueing.value = true
if (!nodePack.id) {
throw new Error('Node ID is required for installation')
}
// Convert 'latest' to actual version number for installation
const actualVersion =
selectedVersion.value === 'latest'
? nodePack.latest_version?.version ?? 'latest'
: selectedVersion.value
await managerStore.installPack.call({
id: nodePack.id,
version: actualVersion,
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: ManagerDatabaseSource.CACHE,
version: selectedVersion.value,
selected_version: selectedVersion.value
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: actualVersion
})
isQueueing.value = false
emit('submit')
}
const getVersionData = (version: string) => {
const latestVersionNumber = nodePack.latest_version?.version
const useLatestVersionData =
version === 'latest' || version === latestVersionNumber
if (useLatestVersionData) {
const latestVersionData = nodePack.latest_version
return {
...latestVersionData
}
}
const versionData = fetchedVersions.value.find((v) => v.version === version)
if (versionData) {
return {
...versionData
}
}
// Fallback to nodePack data
return {
...nodePack
}
}
// Main function to get version compatibility info
const getVersionCompatibility = (version: string) => {
const versionData = getVersionData(version)
const compatibility = checkNodeCompatibility(versionData)
const conflictMessage = compatibility.hasConflict
? getJoinedConflictMessages(compatibility.conflicts, t)
: ''
return {
hasConflict: compatibility.hasConflict,
conflictMessage
}
}
// Helper to determine if an option is selected.
const isOptionSelected = (optionValue: string) => {
if (selectedVersion.value === optionValue) {
return true
}
if (
optionValue === 'latest' &&
selectedVersion.value === nodePack.latest_version?.version
) {
return true
}
return false
}
// Checks if an option is selected, treating 'latest' as an alias for the actual latest version number.
const processedVersionOptions = computed(() => {
return versionOptions.value.map((option) => {
const compatibility = getVersionCompatibility(option.value)
const isSelected = isOptionSelected(option.value)
return {
...option,
hasConflict: compatibility.hasConflict,
conflictMessage: compatibility.conflictMessage,
isSelected: isSelected
}
})
})
</script>

View File

@@ -1,18 +1,24 @@
<template>
<Button
outlined
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
class="!m-0 p-0 max-w-[120px] rounded-lg text-gray-900 dark-theme:text-gray-50"
:class="[
variant === 'black'
? 'bg-neutral-900 text-white border-neutral-900'
: 'border-neutral-700',
: variant === 'red'
? 'border-red-500'
: 'border-neutral-700',
fullWidth ? 'w-full' : 'w-min-content'
]"
:disabled="loading"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2 px-3 whitespace-nowrap">
<span class="py-1.5 px-3 whitespace-nowrap text-xs flex items-center gap-2">
<i
v-if="hasWarning && !loading"
class="pi pi-exclamation-triangle text-yellow-500"
></i>
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>
@@ -28,15 +34,18 @@ import Button from 'primevue/button'
const {
label,
loading = false,
loadingMessage,
fullWidth = false,
variant = 'default'
variant = 'default',
hasWarning = false
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
variant?: 'default' | 'black'
variant?: 'default' | 'black' | 'red'
hasWarning?: boolean
}>()
const emit = defineEmits<{

View File

@@ -11,9 +11,22 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('lodash', () => ({
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
// Mock lodash functions used throughout the app
vi.mock('lodash', async (importOriginal) => {
const actual = await importOriginal<typeof import('lodash')>()
return {
...actual,
debounce: <T extends (...args: any[]) => any>(fn: T) => fn,
memoize: <T extends (...args: any[]) => any>(fn: T) => fn
}
})
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
const mockNodePack = {

View File

@@ -1,10 +1,29 @@
<template>
<div class="flex items-center">
<div class="flex items-center gap-2">
<div
v-if="hasConflict"
v-tooltip="{
value: $t('manager.conflicts.warningTooltip'),
showDelay: 300
}"
class="flex items-center justify-center w-6 h-6 cursor-pointer"
@click="showConflictModal"
>
<i class="pi pi-exclamation-triangle text-yellow-500 text-xl"></i>
</div>
<ToggleSwitch
:model-value="isEnabled"
:disabled="isLoading"
aria-label="Enable or disable pack"
@update:model-value="onToggle"
:class="{
'opacity-50 cursor-not-allowed': isLoading
}"
:pt="{
handle: {
class: 'bg-white'
}
}"
@update:model-value="handleToggleClick"
/>
</div>
</template>
@@ -13,64 +32,131 @@
import { debounce } from 'lodash'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
InstallPackParams,
ManagerChannel,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack } = defineProps<{
const { nodePack, hasConflict } = defineProps<{
nodePack: components['schemas']['Node']
hasConflict?: boolean
}>()
const { isPackEnabled, enablePack, disablePack, installedPacks } =
useComfyManagerStore()
const { t } = useI18n()
const { isPackEnabled, enablePack, disablePack } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgeConflict, isConflictAcknowledged } =
useConflictAcknowledgment()
const isLoading = ref(false)
const isEnabled = computed(() => isPackEnabled(nodePack.id))
const version = computed(() => {
const id = nodePack.id
if (!id) return SelectedVersion.NIGHTLY
return (
installedPacks[id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
)
})
const handleEnable = () =>
enablePack.call({
id: nodePack.id,
version: version.value,
selected_version: version.value,
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: 'default' as InstallPackParams['mode']
})
const handleDisable = () =>
disablePack({
id: nodePack.id,
version: version.value
})
const handleToggle = async (enable: boolean) => {
if (isLoading.value) return
isLoading.value = true
if (enable) {
await handleEnable()
} else {
handleDisable()
const handleEnable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for enabling')
}
isLoading.value = false
return enablePack.call({
id: nodePack.id,
version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
selected_version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
repository: nodePack.repository ?? '',
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
skip_post_install: false
})
}
const onToggle = debounce(handleToggle, TOGGLE_DEBOUNCE_MS, { trailing: true })
const handleDisable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for disabling')
}
return disablePack({
id: nodePack.id,
version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
})
}
const performToggle = async (enable: boolean) => {
if (isLoading.value) return
isLoading.value = true
try {
if (enable) {
await handleEnable()
} else {
await handleDisable()
}
} finally {
isLoading.value = false
}
}
const onToggle = debounce(
(enable: boolean) => {
void performToggle(enable)
},
TOGGLE_DEBOUNCE_MS,
{ trailing: true }
)
const handleToggleClick = async (enable: boolean) => {
if (isLoading.value) return
if (enable && hasConflict) {
const conflicts = getConflictsForPackageByID(nodePack.id || '')
if (conflicts) {
const hasUnacknowledgedConflicts = conflicts.conflicts.some(
(conflict) => !isConflictAcknowledged(nodePack.id || '', conflict.type)
)
if (hasUnacknowledgedConflicts) {
showNodeConflictDialog({
conflictedPackages: [conflicts],
buttonText: t('manager.conflicts.enableAnyway'),
onButtonClick: async () => {
for (const conflict of conflicts.conflicts) {
acknowledgeConflict(nodePack.id || '', conflict.type, '0.1.0')
}
await performToggle(true)
}
})
return
}
}
}
await performToggle(enable)
}
const showConflictModal = () => {
const conflicts = getConflictsForPackageByID(nodePack.id || '')
if (conflicts) {
showNodeConflictDialog({
conflictedPackages: [conflicts],
buttonText: isEnabled.value
? t('manager.conflicts.understood')
: t('manager.conflicts.enableAnyway'),
onButtonClick: async () => {
for (const conflict of conflicts.conflicts) {
acknowledgeConflict(nodePack.id || '', conflict.type, '0.1.0')
}
if (!isEnabled.value) {
onToggle(true)
}
}
})
}
}
</script>

View File

@@ -9,6 +9,7 @@
:variant="variant"
:loading="isInstalling"
:loading-message="$t('g.installing')"
:has-warning="hasConflict"
@action="installAllPacks"
@click="onClick"
/>
@@ -19,43 +20,46 @@ import { inject, ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
IsInstallingKey,
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, variant, label } = defineProps<{
const { nodePacks, variant, label, hasConflict } = defineProps<{
nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
hasConflict?: boolean
}>()
const isInstalling = inject(IsInstallingKey, ref(false))
const managerStore = useComfyManagerStore()
const onClick = (): void => {
isInstalling.value = true
}
const managerStore = useComfyManagerStore()
const createPayload = (
installItem: NodePack
): ManagerComponents['schemas']['InstallPackParams'] => {
if (!installItem.id) {
throw new Error('Node ID is required for installation')
}
const createPayload = (installItem: NodePack) => {
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? SelectedVersion.NIGHTLY
: installItem.latest_version?.version ?? SelectedVersion.LATEST
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
: installItem.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
return {
id: installItem.id,
version: versionToInstall,
repository: installItem.repository ?? '',
channel: ManagerChannel.DEV,
mode: ManagerDatabaseSource.CACHE,
selected_version: versionToInstall,
version: versionToInstall
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: versionToInstall
}
}
@@ -65,14 +69,40 @@ const installPack = (item: NodePack) =>
const installAllPacks = async () => {
if (!nodePacks?.length) return
isInstalling.value = true
// TBD Install Anyway modal
// if (hasConflict && !isConflictAcknowledged) {
// showNodeConflictDialog({
// conflictedPackages: nodePacks,
// buttonText: t('manager.conflicts.installAnyway'),
// onButtonClick: async () => {
// // User chose "Install Anyway" - acknowledge all conflicts and proceed
// for (const conflictedPack of packsWithConflicts) {
// for (const conflict of conflictedPack.conflicts) {
// acknowledgeConflict(
// conflictedPack.package_id,
// conflict.type,
// '0.1.0'
// )
// }
// }
// // Proceed with installation
// await performInstallation(uninstalledPacks)
// }
// })
// return
// }
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await Promise.all(uninstalledPacks.map(installPack))
// No conflicts or conflicts acknowledged - proceed with installation
await performInstallation(uninstalledPacks)
}
const performInstallation = async (packs: NodePack[]) => {
await Promise.all(packs.map(installPack))
managerStore.installPack.clear()
}
</script>

View File

@@ -6,7 +6,7 @@
? $t('manager.uninstallSelected')
: $t('manager.uninstall')
"
severity="danger"
variant="red"
:loading-message="$t('manager.uninstalling')"
@action="uninstallItems"
/>
@@ -15,7 +15,6 @@
<script setup lang="ts">
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { ManagerPackInfo } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
@@ -26,16 +25,16 @@ const { nodePacks } = defineProps<{
const managerStore = useComfyManagerStore()
const createPayload = (uninstallItem: NodePack): ManagerPackInfo => {
return {
id: uninstallItem.id,
version: uninstallItem.latest_version?.version
const uninstallPack = (item: NodePack) => {
if (!item.id) {
throw new Error('Node ID is required for uninstallation')
}
return managerStore.uninstallPack({
id: item.id,
version: item.latest_version?.version ?? ''
})
}
const uninstallPack = (item: NodePack) =>
managerStore.uninstallPack(createPayload(item))
const uninstallItems = async () => {
if (!nodePacks?.length) return
await Promise.all(nodePacks.map(uninstallPack))

View File

@@ -0,0 +1,78 @@
<template>
<PackActionButton
v-bind="$attrs"
variant="black"
:label="$t('manager.updateAll')"
:loading="isUpdating"
:loading-message="$t('g.updating')"
@action="updateAllPacks"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{
nodePacks: NodePack[]
}>()
const isUpdating = ref<boolean>(false)
const managerStore = useComfyManagerStore()
const createPayload = (updateItem: NodePack) => {
return {
id: updateItem.id!,
version: updateItem.latest_version!.version!
}
}
const updatePack = async (item: NodePack) => {
if (!item.id || !item.latest_version?.version) {
console.warn('Pack missing required id or version:', item)
return
}
await managerStore.updatePack.call(createPayload(item))
}
const updateAllPacks = async () => {
if (!nodePacks?.length) {
console.warn('No packs provided for update')
return
}
isUpdating.value = true
const updatablePacks = nodePacks.filter((pack) =>
managerStore.isPackInstalled(pack.id)
)
if (!updatablePacks.length) {
console.info('No installed packs available for update')
isUpdating.value = false
return
}
console.info(`Starting update of ${updatablePacks.length} packs`)
try {
await Promise.all(updatablePacks.map(updatePack))
managerStore.updatePack.clear()
console.info('All packs updated successfully')
} catch (error) {
console.error('Pack update failed:', error)
console.error(
'Failed packs info:',
updatablePacks.map((p) => p.id)
)
} finally {
isUpdating.value = false
}
}
</script>

View File

@@ -2,11 +2,14 @@
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
<InfoPanelHeader
:node-packs="[nodePack]"
:has-conflict="hasCompatibilityIssues"
/>
</div>
<div
ref="scrollContainer"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm scrollbar-hide"
>
<div class="mb-6">
<MetadataRow
@@ -29,6 +32,7 @@
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
:has-compatibility-issues="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
@@ -36,7 +40,11 @@
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs :node-pack="nodePack" />
<InfoTabs
:node-pack="nodePack"
:has-compatibility-issues="hasCompatibilityIssues"
:conflict-result="conflictResult"
/>
</div>
</div>
</div>
@@ -59,9 +67,12 @@ import PackEnableToggle from '@/components/dialog/content/manager/button/PackEna
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
interface InfoItem {
key: string
@@ -75,18 +86,46 @@ const { nodePack } = defineProps<{
const scrollContainer = ref<HTMLElement | null>(null)
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack.id))
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
whenever(isInstalled, () => {
isInstalling.value = false
})
const { isPackInstalled } = useComfyManagerStore()
const { checkNodeCompatibility } = useConflictDetection()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { t, d, n } = useI18n()
// Check compatibility once and pass to children
const conflictResult = computed((): ConflictDetectionResult | null => {
// For installed packages, use stored conflict data
if (isInstalled.value && nodePack.id) {
return getConflictsForPackageByID(nodePack.id) || null
}
// For non-installed packages, perform compatibility check
const compatibility = checkNodeCompatibility(nodePack)
if (compatibility.hasConflict) {
return {
package_id: nodePack.id || '',
package_name: nodePack.name || '',
has_conflict: true,
conflicts: compatibility.conflicts,
is_compatible: false
}
}
return null
})
const hasCompatibilityIssues = computed(() => {
return conflictResult.value?.has_conflict
})
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
@@ -128,17 +167,3 @@ whenever(
{ immediate: true }
)
</script>
<style scoped>
.hidden-scrollbar {
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -1,14 +1,14 @@
<template>
<div v-if="nodePacks?.length" class="flex flex-col items-center mb-6">
<div v-if="nodePacks?.length" class="flex flex-col items-center">
<slot name="thumbnail">
<PackIcon :node-pack="nodePacks[0]" width="24" height="24" />
<PackIcon :node-pack="nodePacks[0]" width="204" height="106" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">
{{ nodePacks[0].name }}
<span class="inline-block text-base">{{ nodePacks[0].name }}</span>
</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
@@ -18,11 +18,16 @@
v-bind="$attrs"
:node-packs="nodePacks"
/>
<PackInstallButton v-else v-bind="$attrs" :node-packs="nodePacks" />
<PackInstallButton
v-else
v-bind="$attrs"
:node-packs="nodePacks"
:has-conflict="hasConflict"
/>
</slot>
</div>
</div>
<div v-else class="flex flex-col items-center mb-6">
<div v-else class="flex flex-col items-center">
<NoResultsPlaceholder
:message="$t('manager.status.unknown')"
:title="$t('manager.tryAgainLater')"
@@ -40,8 +45,9 @@ import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { components } from '@/types/comfyRegistryTypes'
const { nodePacks } = defineProps<{
const { nodePacks, hasConflict } = defineProps<{
nodePacks: components['schemas']['Node'][]
hasConflict?: boolean
}>()
const managerStore = useComfyManagerStore()

View File

@@ -6,8 +6,12 @@
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
{{ nodePacks.length }}
{{ $t('manager.packsSelected') }}
<div class="mt-5">
<span class="inline-block mr-2 text-blue-500 text-base">{{
nodePacks.length
}}</span>
<span class="text-base">{{ $t('manager.packsSelected') }}</span>
</div>
</template>
<template #install-button>
<PackInstallButton :full-width="true" :node-packs="nodePacks" />

View File

@@ -1,15 +1,31 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList>
<Tab value="description">
<TabList class="overflow-x-auto scrollbar-hide">
<Tab v-if="hasCompatibilityIssues" value="warning" class="p-2 mr-6">
<div class="flex items-center gap-1">
<span></span>
{{ $t('g.warning') }}
</div>
</Tab>
<Tab value="description" class="p-2 mr-6">
{{ $t('g.description') }}
</Tab>
<Tab value="nodes">
<Tab value="nodes" class="p-2">
{{ $t('g.nodes') }}
</Tab>
</TabList>
<TabPanels class="overflow-auto">
<TabPanels class="overflow-auto py-4 px-2">
<TabPanel
v-if="hasCompatibilityIssues"
value="warning"
class="bg-transparent"
>
<WarningTabPanel
:node-pack="nodePack"
:conflict-result="conflictResult"
/>
</TabPanel>
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
@@ -27,14 +43,18 @@ import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, ref } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import WarningTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/WarningTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
const { nodePack } = defineProps<{
const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
hasCompatibilityIssues?: boolean
conflictResult?: ConflictDetectionResult | null
}>()
const nodeNames = computed(() => {
@@ -44,4 +64,17 @@ const nodeNames = computed(() => {
})
const activeTab = ref('description')
// Watch for compatibility issues and automatically switch to warning tab
watchEffect(
() => {
if (hasCompatibilityIssues) {
activeTab.value = 'warning'
} else if (activeTab.value === 'warning') {
// If currently on warning tab but no issues, switch to description
activeTab.value = 'description'
}
},
{ flush: 'post' }
)
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-1">
<div class="mb-3">
{{ section.title }}
</div>
<div class="text-muted break-words">

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex py-1.5 text-xs">
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}:</div>
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}</div>
<div class="w-2/3">
<slot>{{ value }}</slot>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="mt-4 overflow-hidden">
<div class="overflow-hidden">
<InfoTextSection
v-if="nodePack?.description"
:sections="descriptionSections"

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-4 mt-4 text-sm">
<div class="flex flex-col gap-4 text-sm">
<template v-if="mappedNodeDefs?.length">
<div
v-for="nodeDef in mappedNodeDefs"

View File

@@ -0,0 +1,24 @@
<template>
<div class="flex flex-col gap-3">
<div
v-for="(conflict, index) in conflictResult?.conflicts || []"
:key="index"
class="p-3 bg-yellow-800/20 rounded-md"
>
<div class="text-sm break-words">
{{ getConflictMessage(conflict, $t) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
const { conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
conflictResult?: ConflictDetectionResult | null
}>()
</script>

View File

@@ -43,7 +43,7 @@
</span>
<p
v-if="nodePack.description"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden max-h-12 line-clamp-3 my-0 leading-4 mb-2 overflow-hidden"
>
{{ nodePack.description }}
</p>
@@ -119,12 +119,20 @@ provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !isPackEnabled(nodePack?.id)
)
const isDisabled = ref(false)
const managerStore = useComfyManagerStore()
whenever(isInstalled, () => (isInstalling.value = false))
// Watch the installedPacks object directly (which gets updated from WebSocket)
whenever(
() => managerStore.installedPacksIds,
() => {
const isInstalled = isPackInstalled(nodePack?.id)
isDisabled.value = isInstalled && !isPackEnabled(nodePack?.id)
// Update isInstalling state after installation is complete
if (isInstalling.value && isInstalled) isInstalling.value = false
}
)
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined

View File

@@ -1,13 +1,22 @@
<template>
<div
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="h-12 flex justify-between items-center px-4 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
</div>
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
<PackEnableToggle v-else :node-pack="nodePack" />
<div class="flex justify-end items-center gap-2">
<template v-if="!isInstalled">
<PackInstallButton
:node-packs="[nodePack]"
:has-conflict="hasConflict"
/>
</template>
<template v-else>
<PackEnableToggle :node-pack="nodePack" :has-conflict="hasConflict" />
</template>
</div>
</div>
</template>
@@ -17,7 +26,9 @@ import { useI18n } from 'vue-i18n'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
@@ -32,4 +43,24 @@ const { n } = useI18n()
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { checkNodeCompatibility } = useConflictDetection()
const hasConflict = computed(() => {
if (!nodePack.id) return false
// For installed packages, check conflicts from store
if (isInstalled.value) {
// Try exact match first
let conflicts = getConflictsForPackageByID(nodePack.id)
if (conflicts) return true
return false
}
// For uninstalled packages, check compatibility directly
const compatibility = checkNodeCompatibility(nodePack)
return compatibility.hasConflict
})
</script>

View File

@@ -20,7 +20,10 @@
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isInstalled">
<PackEnableToggle :node-pack="nodePack" />
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="!!packageConflicts"
/>
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
@@ -35,6 +38,7 @@ import { computed } from 'vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
@@ -42,5 +46,13 @@ const { nodePack } = defineProps<{
}>()
const { isPackInstalled } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const packageConflicts = computed(() => {
if (!nodePack.id || !isInstalled.value) return null
// For installed packages, check conflicts from store
return getConflictsForPackageByID(nodePack.id)
})
</script>

View File

@@ -1,11 +1,37 @@
<template>
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg max-h-72 max-w-72"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
<div class="w-full max-w-[204] aspect-[2/1] rounded-lg overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
@@ -13,29 +39,14 @@ import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '4.5rem',
height = '4.5rem'
} = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
width?: string
height?: string
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.icon || nodePack.icon.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_ICON : nodePack.icon
)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>

View File

@@ -1,25 +1,19 @@
<template>
<div class="relative w-24 h-24">
<div class="relative w-[224px] h-[104px] shadow-xl">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute"
class="absolute w-[210px] h-[90px]"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div class="border rounded-lg p-0.5">
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
<div class="border rounded-lg shadow-lg p-0.5">
<PackIcon :node-pack="pack" />
</div>
</div>
<div
v-if="nodePacks.length > maxVisible"
class="absolute -top-2 -right-2 bg-primary rounded-full w-7 h-7 flex items-center justify-center text-xs font-bold shadow-md z-10"
>
+{{ nodePacks.length - maxVisible }}
</div>
</div>
</template>

View File

@@ -33,6 +33,10 @@
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
/>
<PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="updateAvailableNodePacks"
/>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
@@ -65,8 +69,10 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/components/dialog/content/manager/button/PackUpdateButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import {
type SearchOption,
SortableAlgoliaField
@@ -83,6 +89,7 @@ const { searchResults, sortOptions } = defineProps<{
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery')
@@ -96,6 +103,10 @@ const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes
const { hasUpdateAvailable, updateAvailableNodePacks } =
useUpdateAvailableNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)

View File

@@ -1,41 +1,44 @@
<template>
<div
class="w-full px-6 py-4 shadow-lg flex items-center justify-between"
class="w-full px-6 py-2 shadow-lg flex items-center justify-between"
:class="{
'rounded-t-none': progressDialogContent.isExpanded,
'rounded-lg': !progressDialogContent.isExpanded
}"
>
<div class="justify-center text-sm font-bold leading-none">
<div class="flex items-center text-base leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<i class="pi pi-spin pi-spinner mr-2 text-3xl" />
<DotSpinner duration="1s" class="mr-2" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else-if="isRestartCompleted">
<span class="mr-2">🎉</span>
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<i class="pi pi-check-circle mr-2 text-green-500" />
<span class="leading-none">{{
$t('manager.restartToApplyChanges')
}}</span>
<span class="mr-2"></span>
<span>{{ $t('manager.restartToApplyChanges') }}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-xs font-bold text-neutral-600">
{{ comfyManagerStore.uncompletedCount }} {{ $t('g.progressCountOf') }}
<span v-if="isInProgress" class="text-sm text-neutral-700">
{{ completedTasksCount }} {{ $t('g.progressCountOf') }}
{{ comfyManagerStore.taskLogs.length }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress"
v-if="!isInProgress && !isRestartCompleted"
rounded
outlined
class="px-4 py-2 rounded-md mr-4"
class="mr-4 rounded-md border-2 px-3 text-neutral-600 border-neutral-900 hover:bg-neutral-100 !dark-theme:bg-transparent dark-theme:text-white dark-theme:border-white dark-theme:hover:bg-neutral-800"
@click="handleRestart"
>
{{ $t('g.restart') }}
{{ $t('manager.applyChanges') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
:icon="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
@@ -44,6 +47,7 @@
text
rounded
size="small"
class="font-bold"
severity="secondary"
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
@click.stop="progressDialogContent.toggle"
@@ -53,6 +57,7 @@
text
rounded
size="small"
class="font-bold"
severity="secondary"
aria-label="Close"
@click.stop="closeDialog"
@@ -65,9 +70,10 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { api } from '@/scripts/api'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useWorkflowService } from '@/services/workflowService'
@@ -77,41 +83,93 @@ import {
} from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useSettingStore } from '@/stores/settingStore'
const { t } = useI18n()
const dialogStore = useDialogStore()
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const isInProgress = computed(() => comfyManagerStore.uncompletedCount > 0)
// State management for restart process
const isRestarting = ref<boolean>(false)
const isRestartCompleted = ref<boolean>(false)
const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const completedTasksCount = computed(() => {
return (
comfyManagerStore.succeededTasksIds.length +
comfyManagerStore.failedTasksIds.length
)
})
const closeDialog = () => {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
}
const fallbackTaskName = t('g.installing')
const fallbackTaskName = t('manager.installingDependencies')
const currentTaskName = computed(() => {
if (isRestarting.value) {
return t('manager.restartingBackend')
}
if (isRestartCompleted.value) {
return t('manager.extensionsSuccessfullyInstalled')
}
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? fallbackTaskName
})
const handleRestart = async () => {
const onReconnect = async () => {
// Refresh manager state
// Store original toast setting value
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
comfyManagerStore.clearLogs()
comfyManagerStore.setStale()
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
// Refresh node definitions
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
isRestarting.value = true
// Reload workflow
await useWorkflowService().reloadCurrentWorkflow()
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
} finally {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
closeDialog()
comfyManagerStore.resetTaskState()
}, 3000)
}
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
} catch (error) {
// If restart fails, restore settings and reset state
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = false
closeDialog() // Close dialog on error
throw error
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
closeDialog()
}
</script>

View File

@@ -18,16 +18,40 @@
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useManagerProgressDialogStore } from '@/stores/comfyManagerStore'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const activeTabIndex = ref(0)
const comfyManagerStore = useComfyManagerStore()
const activeTabIndex = computed({
get: () => progressDialogContent.getActiveTabIndex(),
set: (value: number) => progressDialogContent.setActiveTabIndex(value)
})
const { t } = useI18n()
const tabs = [
{ label: t('manager.installationQueue') },
{ label: t('manager.failed', { count: 0 }) }
]
const failedCount = computed(() => comfyManagerStore.failedTasksIds.length)
const queueSuffix = computed(() => {
const queueLength = comfyManagerStore.managerQueue.queueLength
if (queueLength === 0) {
return ''
}
return ` (${queueLength})`
})
const failedSuffix = computed(() => {
if (failedCount.value === 0) {
return ''
}
return ` (${failedCount.value})`
})
const tabs = computed(() => [
{ label: t('manager.installationQueue') + queueSuffix.value },
{ label: t('manager.failed') + failedSuffix.value }
])
</script>

View File

@@ -41,7 +41,15 @@ const previousCanvasDraggable = ref(true)
const onEdit = (newValue: string) => {
if (titleEditorStore.titleEditorTarget && newValue.trim() !== '') {
titleEditorStore.titleEditorTarget.title = newValue.trim()
const trimmedTitle = newValue.trim()
titleEditorStore.titleEditorTarget.title = trimmedTitle
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
const target = titleEditorStore.titleEditorTarget
if (target instanceof LGraphNode && target.isSubgraphNode?.()) {
target.subgraph.name = trimmedTitle
}
app.graph.setDirtyCanvas(true, true)
}
showInput.value = false

View File

@@ -14,7 +14,17 @@
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<i :class="menuItem.icon" class="help-menu-icon" />
<div class="help-menu-icon-container">
<div class="help-menu-icon">
<component
:is="menuItem.icon"
v-if="typeof menuItem.icon === 'object'"
:size="16"
/>
<i v-else :class="menuItem.icon" />
</div>
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
</div>
<span class="menu-label">{{ menuItem.label }}</span>
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
</button>
@@ -54,7 +64,7 @@
</Teleport>
<!-- What's New Section -->
<section class="whats-new-section">
<section v-if="showVersionUpdates" class="whats-new-section">
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
<!-- Release Items -->
@@ -119,25 +129,38 @@
</template>
<script setup lang="ts">
// No need to import useStorage anymore - it's in the composable
import Button from 'primevue/button'
import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
import {
type CSSProperties,
type Component,
computed,
nextTick,
onMounted,
ref
} from 'vue'
import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useConflictBannerState } from '@/composables/useConflictBannerState'
import { useDialogService } from '@/services/dialogService'
import { type ReleaseNote } from '@/services/releaseService'
import { useCommandStore } from '@/stores/commandStore'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
// Types
interface MenuItem {
key: string
icon?: string
icon?: string | Component
label?: string
action?: () => void
visible?: boolean
type?: 'item' | 'divider'
items?: MenuItem[]
showRedDot?: boolean
}
// Constants
@@ -161,13 +184,15 @@ const TIME_UNITS = {
const SUBMENU_CONFIG = {
DELAY_MS: 100,
OFFSET_PX: 8,
Z_INDEX: 1002
Z_INDEX: 10001
} as const
// Composables
const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogService = useDialogService()
// Emits
const emit = defineEmits<{
@@ -182,11 +207,19 @@ let hoverTimeout: number | null = null
// Computed
const hasReleases = computed(() => releaseStore.releases.length > 0)
const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
// Use conflict banner state from composable
const conflictBannerState = useConflictBannerState()
const { shouldShowConflictRedDot: shouldShowManagerRedDot } =
conflictBannerState
const menuItems = computed<MenuItem[]>(() => {
const moreItems: MenuItem[] = [
{
@@ -266,6 +299,17 @@ const menuItems = computed<MenuItem[]>(() => {
emit('close')
}
},
{
key: 'manager',
type: 'item',
icon: PuzzleIcon,
label: t('helpCenter.managerExtension'),
showRedDot: shouldShowManagerRedDot.value,
action: () => {
dialogService.showManagerDialog()
emit('close')
}
},
{
key: 'more',
type: 'item',
@@ -500,16 +544,39 @@ onMounted(async () => {
box-shadow: none;
}
.help-menu-icon {
.help-menu-icon-container {
position: relative;
margin-right: 0.75rem;
width: 16px;
flex-shrink: 0;
}
.help-menu-icon {
font-size: 1rem;
color: var(--p-text-muted-color);
width: 16px;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.help-menu-icon svg {
color: var(--p-text-muted-color);
}
.menu-red-dot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #ff3b30;
border-radius: 50%;
border: 1.5px solid var(--p-content-background);
z-index: 1;
}
.menu-label {
flex: 1;
}

View File

@@ -32,28 +32,32 @@
<div class="whats-new-popup" @click.stop>
<!-- Close Button -->
<button class="close-button" aria-label="Close" @click="closePopup">
<button
class="close-button"
:aria-label="$t('g.close')"
@click="closePopup"
>
<div class="close-icon"></div>
</button>
<!-- Release Content -->
<div class="popup-content">
<div class="content-text" v-html="formattedContent"></div>
</div>
<!-- Actions Section -->
<div class="popup-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
@click="closePopup"
>
{{ $t('whatsNewPopup.learnMore') }}
</a>
<!-- TODO: CTA button -->
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
<!-- Actions Section -->
<div class="popup-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
@click="closePopup"
>
{{ $t('whatsNewPopup.learnMore') }}
</a>
<!-- TODO: CTA button -->
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
</div>
</div>
</div>
</div>
@@ -68,9 +72,14 @@ import type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
const { locale } = useI18n()
const { locale, t } = useI18n()
const releaseStore = useReleaseStore()
// Emit event for parent component
const emit = defineEmits<{
'whats-new-dismissed': []
}>()
// Local state for dismissed status
const isDismissed = ref(false)
@@ -101,13 +110,12 @@ const changelogUrl = computed(() => {
// Format release content for display using marked
const formattedContent = computed(() => {
if (!latestRelease.value?.content) {
return '<p>No release notes available.</p>'
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
}
try {
// Use marked to parse markdown to HTML
return marked(latestRelease.value.content, {
breaks: true, // Convert line breaks to <br>
gfm: true // Enable GitHub Flavored Markdown
})
} catch (error) {
@@ -131,6 +139,10 @@ const closePopup = async () => {
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
}
hide()
// Emit event to notify parent that What's New was dismissed
// Parent can then check if conflict modal should be shown
emit('whats-new-dismissed')
}
// Learn more handled by anchor href
@@ -199,14 +211,10 @@ defineExpose({
}
.whats-new-popup {
padding: 32px 32px 24px;
background: #353535;
border-radius: 12px;
max-width: 400px;
width: 400px;
display: flex;
flex-direction: column;
gap: 32px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
box-shadow: 0px 8px 32px rgba(0, 0, 0, 0.3);
@@ -217,6 +225,11 @@ defineExpose({
.popup-content {
display: flex;
flex-direction: column;
gap: 24px;
max-height: 80vh;
overflow-y: auto;
padding: 32px 32px 24px;
border-radius: 12px;
}
/* Close button */
@@ -224,17 +237,17 @@ defineExpose({
position: absolute;
top: 0;
right: 0;
width: 31px;
height: 31px;
padding: 6px 7px;
width: 32px;
height: 32px;
padding: 6px;
background: #7c7c7c;
border-radius: 15.5px;
border-radius: 16px;
border: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
transform: translate(50%, -50%);
transform: translate(30%, -30%);
transition:
background-color 0.2s ease,
transform 0.1s ease;
@@ -247,7 +260,7 @@ defineExpose({
.close-button:active {
background: #6a6a6a;
transform: translate(50%, -50%) scale(0.95);
transform: translate(30%, -30%) scale(0.95);
}
.close-icon {
@@ -288,73 +301,45 @@ defineExpose({
.content-text {
color: white;
font-size: 14px;
font-family: 'Inter', sans-serif;
font-weight: 400;
line-height: 1.5;
word-wrap: break-word;
}
/* Style the markdown content */
/* Title */
.content-text :deep(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.content-text :deep(h1) {
color: white;
font-size: 20px;
font-weight: 700;
margin: 0 0 16px 0;
line-height: 1.3;
}
.content-text :deep(h2) {
color: white;
font-size: 18px;
font-weight: 600;
margin: 16px 0 12px 0;
line-height: 1.3;
}
.content-text :deep(h2:first-child) {
margin-top: 0;
}
.content-text :deep(h3) {
color: white;
font-size: 16px;
font-weight: 600;
margin: 12px 0 8px 0;
line-height: 1.3;
font-weight: 700;
margin-bottom: 8px;
}
.content-text :deep(h3:first-child) {
margin-top: 0;
}
.content-text :deep(h4) {
color: white;
font-size: 14px;
font-weight: 600;
margin: 8px 0 6px 0;
}
.content-text :deep(h4:first-child) {
margin-top: 0;
/* Version subtitle - targets the first p tag after h1 */
.content-text :deep(h1 + p) {
color: #c0c0c0;
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
opacity: 0.8;
}
/* Regular paragraphs - short description */
.content-text :deep(p) {
margin: 0 0 12px 0;
line-height: 1.6;
}
.content-text :deep(p:first-child) {
margin-top: 0;
}
.content-text :deep(p:last-child) {
margin-bottom: 0;
margin-bottom: 16px;
color: #e0e0e0;
}
/* List */
.content-text :deep(ul),
.content-text :deep(ol) {
margin: 0 0 12px 0;
padding-left: 24px;
margin-bottom: 16px;
padding-left: 0;
list-style: none;
}
.content-text :deep(ul:first-child),
@@ -367,12 +352,63 @@ defineExpose({
margin-bottom: 0;
}
/* List items */
.content-text :deep(li) {
margin-bottom: 8px;
position: relative;
padding-left: 20px;
}
.content-text :deep(li:last-child) {
margin-bottom: 0;
}
/* Custom bullet points */
.content-text :deep(li::before) {
content: '';
position: absolute;
left: 0;
top: 10px;
display: flex;
width: 8px;
height: 8px;
justify-content: center;
align-items: center;
aspect-ratio: 1/1;
border-radius: 100px;
background: #60a5fa;
}
/* List item strong text */
.content-text :deep(li strong) {
color: #fff;
font-size: 14px;
display: block;
margin-bottom: 4px;
}
.content-text :deep(li p) {
font-size: 12px;
margin-bottom: 0;
line-height: 2;
}
/* Code styling */
.content-text :deep(code) {
background-color: #2a2a2a;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 2px 6px;
color: #f8f8f2;
white-space: nowrap;
}
/* Remove top margin for first media element */
.content-text :deep(img:first-child),
.content-text :deep(video:first-child),
.content-text :deep(iframe:first-child) {
margin-top: -32px; /* Align with the top edge of the popup content */
margin-bottom: 12px;
margin-bottom: 24px;
}
/* Media elements */
@@ -381,8 +417,7 @@ defineExpose({
.content-text :deep(iframe) {
width: calc(100% + 64px);
height: auto;
border-radius: 6px;
margin: 12px -32px;
margin: 24px -32px;
display: block;
}
@@ -397,7 +432,6 @@ defineExpose({
.learn-more-link {
color: #60a5fa;
font-size: 14px;
font-family: 'Inter', sans-serif;
font-weight: 500;
line-height: 18.2px;
text-decoration: none;
@@ -417,7 +451,6 @@ defineExpose({
border: none;
color: #121212;
font-size: 14px;
font-family: 'Inter', sans-serif;
font-weight: 500;
cursor: pointer;
}

View File

@@ -0,0 +1,43 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 16 16"
fill="none"
:class="iconClass"
>
<g clip-path="url(#clip0_1099_16244)">
<path
d="M4.99992 3.00016C4.99992 2.07969 5.74611 1.3335 6.66658 1.3335C7.58706 1.3335 8.33325 2.07969 8.33325 3.00016V4.00016H8.99992C9.9318 4.00016 10.3977 4.00016 10.7653 4.1524C11.2553 4.35539 11.6447 4.74474 11.8477 5.2348C11.9999 5.60234 11.9999 6.06828 11.9999 7.00016H12.9999C13.9204 7.00016 14.6666 7.74635 14.6666 8.66683C14.6666 9.5873 13.9204 10.3335 12.9999 10.3335H11.9999V11.4668C11.9999 12.5869 11.9999 13.147 11.7819 13.5748C11.5902 13.9511 11.2842 14.2571 10.9079 14.4488C10.4801 14.6668 9.92002 14.6668 8.79992 14.6668H8.33325V13.5002C8.33325 12.6717 7.66168 12.0002 6.83325 12.0002C6.00482 12.0002 5.33325 12.6717 5.33325 13.5002V14.6668H4.53325C3.41315 14.6668 2.85309 14.6668 2.42527 14.4488C2.04895 14.2571 1.74299 13.9511 1.55124 13.5748C1.33325 13.147 1.33325 12.5869 1.33325 11.4668V10.3335H2.33325C3.25373 10.3335 3.99992 9.5873 3.99992 8.66683C3.99992 7.74635 3.25373 7.00016 2.33325 7.00016H1.33325C1.33325 6.06828 1.33325 5.60234 1.48549 5.2348C1.68848 4.74474 2.07783 4.35539 2.56789 4.1524C2.93543 4.00016 3.40137 4.00016 4.33325 4.00016H4.99992V3.00016Z"
:stroke="color"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_1099_16244">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
size?: number | string
color?: string
class?: string
}
const {
size = 16,
color = 'currentColor',
class: className
} = defineProps<Props>()
const iconClass = computed(() => className || '')
</script>

View File

@@ -0,0 +1,29 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 16 16"
fill="none"
:class="iconClass"
>
<path
d="M8.00049 1.3335C8.73661 1.33367 9.33332 1.93038 9.3335 2.6665V2.83447C9.82278 2.96041 10.2851 3.15405 10.7095 3.40479L10.8286 3.28564C11.3493 2.76525 12.1937 2.76519 12.7144 3.28564C13.235 3.80626 13.2348 4.65067 12.7144 5.17139L12.5952 5.29053C12.846 5.71486 13.0396 6.17725 13.1655 6.6665H13.3335C14.0699 6.6665 14.6665 7.26411 14.6665 8.00049C14.6663 8.73672 14.0698 9.3335 13.3335 9.3335H13.1655C13.0396 9.82284 12.846 10.2851 12.5952 10.7095L12.7144 10.8286C13.235 11.3493 13.235 12.1937 12.7144 12.7144C12.1937 13.235 11.3493 13.235 10.8286 12.7144L10.7095 12.5952C10.2851 12.846 9.82284 13.0396 9.3335 13.1655V13.3335C9.3335 14.0698 8.73672 14.6663 8.00049 14.6665C7.26411 14.6665 6.6665 14.0699 6.6665 13.3335V13.1655C6.17725 13.0396 5.71486 12.846 5.29053 12.5952L5.17139 12.7144C4.65067 13.2348 3.80626 13.235 3.28564 12.7144C2.76519 12.1937 2.76525 11.3493 3.28564 10.8286L3.40479 10.7095C3.15405 10.2851 2.96041 9.82278 2.83447 9.3335H2.6665C1.93038 9.33332 1.33367 8.73661 1.3335 8.00049C1.3335 7.26422 1.93027 6.66668 2.6665 6.6665H2.83447C2.96043 6.17722 3.15403 5.71488 3.40479 5.29053L3.28564 5.17139C2.76536 4.65065 2.76508 3.80621 3.28564 3.28564C3.80621 2.76508 4.65065 2.76536 5.17139 3.28564L5.29053 3.40479C5.71488 3.15403 6.17722 2.96043 6.6665 2.83447V2.6665C6.66668 1.93027 7.26422 1.3335 8.00049 1.3335ZM7.3335 8.00049L6.00049 6.6665L4.6665 8.00049L7.3335 10.6665L11.3335 6.6665L10.0005 5.3335L7.3335 8.00049Z"
:fill="color"
/>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
size?: number | string
color?: string
class?: string
}
const { size = 16, color = '#60A5FA', class: className } = defineProps<Props>()
const iconClass = computed(() => className || '')
</script>

View File

@@ -55,7 +55,6 @@
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@upload-texture="handleUploadTexture"
@export-model="handleExportModel"
/>
<div
@@ -215,30 +214,6 @@ const handleBackgroundImageUpdate = async (file: File | null) => {
node.properties['Background Image'] = backgroundImage.value
}
const handleUploadTexture = async (file: File) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const texturePath = await Load3dUtils.uploadFile(file, subfolder)
await load3DSceneRef.value.load3d.applyTexture(texturePath)
node.properties['Texture'] = texturePath
} catch (error) {
console.error('Error applying texture:', error)
useToastStore().addAlert(t('toastMessages.failedToApplyTexture'))
}
}
const handleUpdateFOV = (value: number) => {
fov.value = value

View File

@@ -51,7 +51,6 @@
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@upload-texture="handleUploadTexture"
/>
<CameraControls
@@ -183,7 +182,6 @@ const emit = defineEmits<{
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'exportModel', format: string): void
(e: 'uploadTexture', file: File): void
}>()
const backgroundColor = ref(props.backgroundColor)
@@ -232,10 +230,6 @@ const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUploadTexture = (file: File) => {
emit('uploadTexture', file)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}

View File

@@ -69,9 +69,6 @@ const eventConfig = {
exportLoadingEnd: () => {
loadingOverlayRef.value?.endLoading()
},
textureLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.applyingTexture')),
textureLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
recordingStatusChange: (value: boolean) =>
emit('recordingStatusChange', value)
} as const

View File

@@ -59,32 +59,6 @@
</div>
</div>
<div
v-if="
materialMode === 'original' &&
!props.inputSpec.isAnimation &&
!props.inputSpec.isPreview
"
class="relative show-texture-upload"
>
<Button class="p-button-rounded p-button-text" @click="openTextureUpload">
<i
v-tooltip.right="{
value: t('load3d.uploadTexture'),
showDelay: 300
}"
class="pi pi-image text-white text-lg"
/>
<input
ref="texturePickerRef"
type="file"
accept="image/*"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
@change="uploadTexture"
/>
</Button>
</div>
<div v-if="materialMode === 'lineart'" class="relative show-edge-threshold">
<Button
class="p-button-rounded p-button-text"
@@ -142,7 +116,6 @@ const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'uploadTexture', file: File): void
}>()
const upDirection = ref(props.upDirection || 'original')
@@ -151,7 +124,6 @@ const edgeThreshold = ref(props.edgeThreshold || 85)
const showUpDirection = ref(false)
const showMaterialMode = ref(false)
const showEdgeThreshold = ref(false)
const texturePickerRef = ref<HTMLInputElement | null>(null)
const upDirections: UpDirection[] = [
'original',
@@ -247,18 +219,6 @@ const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const openTextureUpload = () => {
texturePickerRef.value?.click()
}
const uploadTexture = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
emit('uploadTexture', input.files[0])
}
}
const closeSceneSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -42,11 +42,12 @@
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="#graph-canvas-container">
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@@ -57,12 +58,14 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
import { useConflictBannerState } from '@/composables/useConflictBannerState'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useDialogService } from '@/services/dialogService'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -70,9 +73,20 @@ import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const { shouldShowRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
const conflictBannerState = useConflictBannerState()
const dialogService = useDialogService()
const isHelpCenterVisible = ref(false)
// Use conflict banner state from composable
const { shouldShowConflictRedDot } = conflictBannerState
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed(() => {
const releaseRedDot = releaseStore.shouldShowRedDot
return releaseRedDot || shouldShowConflictRedDot.value
})
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
@@ -87,6 +101,44 @@ const closeHelpCenter = () => {
isHelpCenterVisible.value = false
}
/**
* Handle What's New popup dismissal
* Check if conflict modal should be shown after ComfyUI update
*/
const handleWhatsNewDismissed = async () => {
try {
// Check if conflict modal should be shown after update
const shouldShow =
await conflictDetection.shouldShowConflictModalAfterUpdate()
if (shouldShow) {
showConflictModal()
}
} catch (error) {
console.error('[HelpCenter] Error checking conflict modal:', error)
}
}
/**
* Show the node conflict dialog with current conflict data
*/
const showConflictModal = () => {
// Pass conflict data to the dialog, including onClose callback
const conflictData = {
conflictedPackages: conflictDetection.conflictedPackages.value
}
// Show dialog with onClose callback in dialogComponentProps
dialogService.showNodeConflictDialog({
...conflictData,
dialogComponentProps: {
onClose: () => {
conflictDetection.dismissConflictModal()
}
}
})
}
// Initialize release store on mount
onMounted(async () => {
// Initialize release store to fetch releases for toast and popup
@@ -101,14 +153,14 @@ onMounted(async () => {
left: 0;
right: 0;
bottom: 0;
z-index: 999;
z-index: 9999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 1000;
z-index: 10000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}

View File

@@ -159,7 +159,7 @@ const toggleExpanded = () => {
const removeTask = async (task: TaskItemImpl) => {
if (task.isRunning) {
await api.interrupt()
await api.interrupt(task.promptId)
}
await queueStore.delete(task)
}

View File

@@ -151,7 +151,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const renderingSpeed = String(renderingSpeedWidget.value)
if (renderingSpeed.toLowerCase().includes('quality')) {
basePrice = 0.08
basePrice = 0.09
} else if (renderingSpeed.toLowerCase().includes('balanced')) {
basePrice = 0.06
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
@@ -322,15 +322,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const effectScene = String(effectSceneWidget.value)
if (
effectScene.includes('fuzzyfuzzy') ||
effectScene.includes('squish') ||
effectScene.includes('expansion')
effectScene.includes('squish')
) {
return '$0.28/Run'
} else if (
effectScene.includes('dizzydizzy') ||
effectScene.includes('bloombloom')
) {
} else if (effectScene.includes('dizzydizzy')) {
return '$0.49/Run'
} else if (effectScene.includes('bloombloom')) {
return '$0.49/Run'
} else if (effectScene.includes('expansion')) {
return '$0.28/Run'
}
return '$0.28/Run'
@@ -448,12 +448,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
} else if (model.includes('ray-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$6.37/Run'
if (resolution.includes('1080p')) return '$2.30/Run'
if (resolution.includes('1080p')) return '$1.59/Run'
if (resolution.includes('720p')) return '$0.71/Run'
if (resolution.includes('540p')) return '$0.40/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$11.47/Run'
if (resolution.includes('1080p')) return '$4.14/Run'
if (resolution.includes('1080p')) return '$2.87/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
}
@@ -499,12 +499,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
} else if (model.includes('ray-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$6.37/Run'
if (resolution.includes('1080p')) return '$2.30/Run'
if (resolution.includes('1080p')) return '$1.59/Run'
if (resolution.includes('720p')) return '$0.71/Run'
if (resolution.includes('540p')) return '$0.40/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$11.47/Run'
if (resolution.includes('1080p')) return '$4.14/Run'
if (resolution.includes('1080p')) return '$2.87/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
}
@@ -947,6 +947,63 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.0172/Run'
}
},
MoonvalleyTxt2VideoNode: {
displayPrice: (node: LGraphNode): string => {
const lengthWidget = node.widgets?.find(
(w) => w.name === 'length'
) as IComboWidget
// If no length widget exists, default to 5s pricing
if (!lengthWidget) return '$1.50/Run'
const length = String(lengthWidget.value)
if (length === '5s') {
return '$1.50/Run'
} else if (length === '10s') {
return '$3.00/Run'
}
return '$1.50/Run'
}
},
MoonvalleyImg2VideoNode: {
displayPrice: (node: LGraphNode): string => {
const lengthWidget = node.widgets?.find(
(w) => w.name === 'length'
) as IComboWidget
// If no length widget exists, default to 5s pricing
if (!lengthWidget) return '$1.50/Run'
const length = String(lengthWidget.value)
if (length === '5s') {
return '$1.50/Run'
} else if (length === '10s') {
return '$3.00/Run'
}
return '$1.50/Run'
}
},
MoonvalleyVideo2VideoNode: {
displayPrice: (node: LGraphNode): string => {
const lengthWidget = node.widgets?.find(
(w) => w.name === 'length'
) as IComboWidget
// If no length widget exists, default to 5s pricing
if (!lengthWidget) return '$2.25/Run'
const length = String(lengthWidget.value)
if (length === '5s') {
return '$2.25/Run'
} else if (length === '10s') {
return '$4.00/Run'
}
return '$2.25/Run'
}
}
}
@@ -1015,7 +1072,10 @@ export const useNodePricing = () => {
RecraftVectorizeImageNode: ['n'],
RecraftGenerateColorFromImageNode: ['n'],
RecraftGenerateImageNode: ['n'],
RecraftGenerateVectorImageNode: ['n']
RecraftGenerateVectorImageNode: ['n'],
MoonvalleyTxt2VideoNode: ['length'],
MoonvalleyImg2VideoNode: ['length'],
MoonvalleyVideo2VideoNode: ['length']
}
return widgetMap[nodeType] || []
}

View File

@@ -20,7 +20,10 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
const startFetchInstalled = async () => {
await comfyManagerStore.refreshInstalledList()
// Only refresh if store doesn't have data yet
if (comfyManagerStore.installedPacksIds.size === 0) {
await comfyManagerStore.refreshInstalledList()
}
await startFetch()
}
@@ -33,11 +36,29 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
cleanup()
})
// Create a computed property that provides installed pack info with versions
const installedPacksWithVersions = computed(() => {
const result: Array<{ id: string; version: string }> = []
for (const pack of Object.values(comfyManagerStore.installedPacks)) {
const id = pack.cnr_id || pack.aux_id
if (id) {
result.push({
id,
version: pack.ver
})
}
}
return result
})
return {
error,
isLoading,
isReady,
installedPacks: nodePacks,
installedPacksWithVersions,
startFetchInstalled,
filterInstalledPack
}

View File

@@ -0,0 +1,65 @@
import { computed, onMounted } from 'vue'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
/**
* Composable to find NodePacks that have updates available
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches installed pack data when initialized
*/
export const useUpdateAvailableNodes = () => {
const comfyManagerStore = useComfyManagerStore()
const { installedPacks, isLoading, error, startFetchInstalled } =
useInstalledPacks()
// Check if a pack has updates available (same logic as usePackUpdateStatus)
const isOutdatedPack = (pack: components['schemas']['Node']) => {
const isInstalled = comfyManagerStore.isPackInstalled(pack?.id)
if (!isInstalled) return false
const installedVersion = comfyManagerStore.getInstalledPackVersion(
pack.id ?? ''
)
const latestVersion = pack.latest_version?.version
const isNightlyPack = !!installedVersion && !isSemVer(installedVersion)
if (isNightlyPack || !latestVersion) {
return false
}
return compareVersions(latestVersion, installedVersion) > 0
}
// Same filtering logic as ManagerDialogContent.vue
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
packs.filter(isOutdatedPack)
// Filter only outdated packs from installed packs
const updateAvailableNodePacks = computed(() => {
if (!installedPacks.value.length) return []
return filterOutdatedPacks(installedPacks.value)
})
// Check if there are any outdated packs
const hasUpdateAvailable = computed(() => {
return updateAvailableNodePacks.value.length > 0
})
// Automatically fetch installed pack data when composable is used
onMounted(async () => {
if (!installedPacks.value.length && !isLoading.value) {
await startFetchInstalled()
}
})
return {
updateAvailableNodePacks,
hasUpdateAvailable,
isLoading,
error
}
}

View File

@@ -7,7 +7,7 @@ import { app } from '@/scripts/app'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { SelectedVersion, UseNodePacksOptions } from '@/types/comfyManagerTypes'
import { UseNodePacksOptions } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
type WorkflowPack = {
@@ -65,8 +65,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
return {
id: CORE_NODES_PACK_NAME,
version:
systemStatsStore.systemStats?.system?.comfyui_version ??
SelectedVersion.NIGHTLY
systemStatsStore.systemStats?.system?.comfyui_version ?? 'nightly'
}
}
@@ -76,7 +75,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
if (pack) {
return {
id: pack.id,
version: pack.latest_version?.version ?? SelectedVersion.NIGHTLY
version: pack.latest_version?.version ?? 'nightly'
}
}

View File

@@ -53,6 +53,11 @@ export function useSettingSearch() {
const queryLower = query.toLocaleLowerCase()
const allSettings = Object.values(settingStore.settingsById)
const filteredSettings = allSettings.filter((setting) => {
// Filter out hidden and deprecated settings, just like in normal settings tree
if (setting.type === 'hidden' || setting.deprecated) {
return false
}
const idLower = setting.id.toLowerCase()
const nameLower = setting.name.toLowerCase()
const translatedName = st(

View File

@@ -0,0 +1,223 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
/**
* LocalStorage keys for conflict acknowledgment tracking
*/
const STORAGE_KEYS = {
CONFLICT_MODAL_DISMISSED: 'comfy_manager_conflict_banner_dismissed',
CONFLICT_RED_DOT_DISMISSED: 'comfy_help_center_conflict_seen',
ACKNOWLEDGED_CONFLICTS: 'comfy_conflict_acknowledged',
LAST_COMFYUI_VERSION: 'comfyui.last_version'
} as const
/**
* Interface for tracking individual conflict acknowledgments
*/
interface AcknowledgedConflict {
package_id: string
conflict_type: string
timestamp: string
comfyui_version: string
}
/**
* Interface for conflict acknowledgment state
*/
interface ConflictAcknowledgmentState {
modal_dismissed: boolean
red_dot_dismissed: boolean
acknowledged_conflicts: AcknowledgedConflict[]
last_comfyui_version: string
}
/**
* Composable for managing conflict acknowledgment state in localStorage
*
* This handles:
* - Tracking whether conflict modal has been dismissed
* - Tracking whether red dot notification has been cleared
* - Managing per-package conflict acknowledgments
* - Detecting ComfyUI version changes to reset acknowledgment state
*/
export function useConflictAcknowledgment() {
// Reactive state using VueUse's useStorage for automatic persistence
const modalDismissed = useStorage(
STORAGE_KEYS.CONFLICT_MODAL_DISMISSED,
false
)
const redDotDismissed = useStorage(
STORAGE_KEYS.CONFLICT_RED_DOT_DISMISSED,
false
)
const acknowledgedConflicts = useStorage<AcknowledgedConflict[]>(
STORAGE_KEYS.ACKNOWLEDGED_CONFLICTS,
[]
)
const lastComfyUIVersion = useStorage(STORAGE_KEYS.LAST_COMFYUI_VERSION, '')
// Create computed state object for backward compatibility
const state = computed<ConflictAcknowledgmentState>(() => ({
modal_dismissed: modalDismissed.value,
red_dot_dismissed: redDotDismissed.value,
acknowledged_conflicts: acknowledgedConflicts.value,
last_comfyui_version: lastComfyUIVersion.value
}))
/**
* Check if ComfyUI version has changed since last run
* If version changed, reset acknowledgment state
*/
function checkComfyUIVersionChange(currentVersion: string): boolean {
const lastVersion = lastComfyUIVersion.value
const versionChanged = lastVersion !== '' && lastVersion !== currentVersion
if (versionChanged) {
console.log(
`[ConflictAcknowledgment] ComfyUI version changed from ${lastVersion} to ${currentVersion}, resetting acknowledgment state`
)
resetAcknowledgmentState()
}
// Update last known version
lastComfyUIVersion.value = currentVersion
return versionChanged
}
/**
* Reset all acknowledgment state (called when ComfyUI version changes)
*/
function resetAcknowledgmentState(): void {
modalDismissed.value = false
redDotDismissed.value = false
acknowledgedConflicts.value = []
}
/**
* Mark conflict modal as dismissed
*/
function dismissConflictModal(): void {
modalDismissed.value = true
console.log('[ConflictAcknowledgment] Conflict modal dismissed')
}
/**
* Mark red dot notification as dismissed
*/
function dismissRedDotNotification(): void {
redDotDismissed.value = true
console.log('[ConflictAcknowledgment] Red dot notification dismissed')
}
/**
* Acknowledge a specific conflict for a package
*/
function acknowledgeConflict(
packageId: string,
conflictType: string,
comfyuiVersion: string
): void {
const acknowledgment: AcknowledgedConflict = {
package_id: packageId,
conflict_type: conflictType,
timestamp: new Date().toISOString(),
comfyui_version: comfyuiVersion
}
// Remove any existing acknowledgment for the same package and conflict type
acknowledgedConflicts.value = acknowledgedConflicts.value.filter(
(ack) =>
!(ack.package_id === packageId && ack.conflict_type === conflictType)
)
// Add new acknowledgment
acknowledgedConflicts.value.push(acknowledgment)
console.log(
`[ConflictAcknowledgment] Acknowledged conflict for ${packageId}:${conflictType}`
)
}
/**
* Check if a specific conflict has been acknowledged
*/
function isConflictAcknowledged(
packageId: string,
conflictType: string
): boolean {
return acknowledgedConflicts.value.some(
(ack) =>
ack.package_id === packageId && ack.conflict_type === conflictType
)
}
/**
* Remove acknowledgment for a specific conflict
*/
function removeConflictAcknowledgment(
packageId: string,
conflictType: string
): void {
acknowledgedConflicts.value = acknowledgedConflicts.value.filter(
(ack) =>
!(ack.package_id === packageId && ack.conflict_type === conflictType)
)
console.log(
`[ConflictAcknowledgment] Removed acknowledgment for ${packageId}:${conflictType}`
)
}
/**
* Clear all acknowledgments (for debugging/admin purposes)
*/
function clearAllAcknowledgments(): void {
resetAcknowledgmentState()
console.log('[ConflictAcknowledgment] Cleared all acknowledgments')
}
// Computed properties
const shouldShowConflictModal = computed(() => !modalDismissed.value)
const shouldShowRedDot = computed(() => !redDotDismissed.value)
/**
* Get all acknowledged package IDs
*/
const acknowledgedPackageIds = computed(() => {
return Array.from(
new Set(acknowledgedConflicts.value.map((ack) => ack.package_id))
)
})
/**
* Get acknowledgment statistics
*/
const acknowledgmentStats = computed(() => {
return {
total_acknowledged: acknowledgedConflicts.value.length,
unique_packages: acknowledgedPackageIds.value.length,
modal_dismissed: modalDismissed.value,
red_dot_dismissed: redDotDismissed.value,
last_comfyui_version: lastComfyUIVersion.value
}
})
return {
// State
acknowledgmentState: state,
shouldShowConflictModal,
shouldShowRedDot,
acknowledgedPackageIds,
acknowledgmentStats,
// Methods
checkComfyUIVersionChange,
dismissConflictModal,
dismissRedDotNotification,
acknowledgeConflict,
isConflictAcknowledged,
removeConflictAcknowledgment,
clearAllAcknowledgments,
resetAcknowledgmentState
}
}

View File

@@ -0,0 +1,71 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
/**
* Composable for managing conflict banner state across components
* Provides centralized logic for conflict visibility and dismissal
*/
export function useConflictBannerState() {
const conflictDetectionStore = useConflictDetectionStore()
// Storage keys
const HELP_CENTER_CONFLICT_SEEN_KEY = 'comfy_help_center_conflict_seen'
const MANAGER_CONFLICT_BANNER_DISMISSED_KEY =
'comfy_manager_conflict_banner_dismissed'
// Reactive storage state
const hasSeenConflicts = useStorage(HELP_CENTER_CONFLICT_SEEN_KEY, false)
const isConflictBannerDismissed = useStorage(
MANAGER_CONFLICT_BANNER_DISMISSED_KEY,
false
)
// Computed states
const hasConflicts = computed(() => conflictDetectionStore.hasConflicts)
/**
* Check if the help center should show a red dot for conflicts
*/
const shouldShowConflictRedDot = computed(() => {
if (!hasConflicts.value) return false
return !hasSeenConflicts.value
})
/**
* Check if the manager conflict banner should be visible
*/
const shouldShowManagerBanner = computed(() => {
return hasConflicts.value && !isConflictBannerDismissed.value
})
/**
* Mark conflicts as seen (used when user opens manager dialog or help center)
*/
const markConflictsAsSeen = () => {
if (hasConflicts.value) {
hasSeenConflicts.value = true
isConflictBannerDismissed.value = true
// Force localStorage update as backup due to useStorage sync timing issue
// useStorage updates localStorage asynchronously, but we need immediate persistence
localStorage.setItem(HELP_CENTER_CONFLICT_SEEN_KEY, 'true')
localStorage.setItem(MANAGER_CONFLICT_BANNER_DISMISSED_KEY, 'true')
}
}
return {
// State
hasConflicts,
hasSeenConflicts,
isConflictBannerDismissed,
// Computed
shouldShowConflictRedDot,
shouldShowManagerBanner,
// Actions
markConflictsAsSeen
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,10 +15,12 @@ import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { addFluxKontextGroupNode } from '@/scripts/fluxKontextEditNode'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { type ComfyCommand, useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -28,6 +30,7 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
const moveSelectedNodesVersionAdded = '1.22.2'
@@ -39,6 +42,7 @@ export function useCoreCommands(): ComfyCommand[] {
const firebaseAuthActions = useFirebaseAuthActions()
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
const getSelectedNodes = (): LGraphNode[] => {
@@ -203,7 +207,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-stop',
label: 'Interrupt',
function: async () => {
await api.interrupt()
await api.interrupt(executionStore.activePromptId)
toastStore.add({
severity: 'info',
summary: t('g.interrupted'),
@@ -658,12 +662,53 @@ export function useCoreCommands(): ComfyCommand[] {
}
},
{
id: 'Comfy.Manager.CustomNodesManager',
id: 'Comfy.Manager.CustomNodesManager.ShowCustomNodesMenu',
icon: 'pi pi-puzzle',
label: 'Toggle the Custom Nodes Manager',
label: 'Show the Custom Nodes Manager',
versionAdded: '1.12.10',
function: async () => {
const { is_legacy_manager_ui } =
(await useComfyManagerService().isLegacyManagerUI()) ?? {}
if (is_legacy_manager_ui === true) {
try {
await useCommandStore().execute(
'Comfy.Manager.Menu.ToggleVisibility' // This command is registered by legacy manager FE extension
)
} catch (error) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
dialogService.toggleManagerDialog()
}
} else {
dialogService.toggleManagerDialog()
}
}
},
{
id: 'Comfy.Manager.ShowUpdateAvailablePacks',
icon: 'pi pi-sync',
label: 'Check for Updates',
versionAdded: '1.17.0',
function: () => {
dialogService.toggleManagerDialog()
dialogService.showManagerDialog({
initialTab: ManagerTab.UpdateAvailable
})
}
},
{
id: 'Comfy.Manager.ShowMissingPacks',
icon: 'pi pi-exclamation-circle',
label: 'Install Missing',
versionAdded: '1.17.0',
function: () => {
dialogService.showManagerDialog({
initialTab: ManagerTab.Missing
})
}
},
{
@@ -752,9 +797,88 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
const { node } = res
canvas.select(node)
}
},
{
id: 'Comfy.Manager.CustomNodesManager.ShowLegacyCustomNodesMenu',
icon: 'pi pi-bars',
label: 'Custom Nodes (Legacy)',
versionAdded: '1.16.4',
function: async () => {
try {
await useCommandStore().execute(
'Comfy.Manager.CustomNodesManager.ToggleVisibility'
)
} catch (error) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
}
},
{
id: 'Comfy.Manager.ShowLegacyManagerMenu',
icon: 'mdi mdi-puzzle',
label: 'Manager Menu (Legacy)',
versionAdded: '1.16.4',
function: async () => {
try {
await useCommandStore().execute('Comfy.Manager.Menu.ToggleVisibility')
} catch (error) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
}
},
{
id: 'Comfy.Memory.UnloadModels',
icon: 'mdi mdi-vacuum-outline',
label: 'Unload Models',
versionAdded: '1.16.4',
function: async () => {
if (!useSettingStore().get('Comfy.Memory.AllowManualUnload')) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModels'
}),
life: 3000
})
return
}
await api.freeMemory({ freeExecutionCache: false })
}
},
{
id: 'Comfy.Memory.UnloadModelsAndExecutionCache',
icon: 'mdi mdi-vacuum-outline',
label: 'Unload Models and Execution Cache',
versionAdded: '1.16.4',
function: async () => {
if (!useSettingStore().get('Comfy.Memory.AllowManualUnload')) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
}),
life: 3000
})
return
}
await api.freeMemory({ freeExecutionCache: true })
}
}
]

View File

@@ -1,101 +1,153 @@
import { useEventListener, whenever } from '@vueuse/core'
import { computed, readonly, ref } from 'vue'
import { mapKeys, pickBy } from 'lodash'
import { Ref, computed, ref } from 'vue'
import { api } from '@/scripts/api'
import { ManagerWsQueueStatus } from '@/types/comfyManagerTypes'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { components } from '@/types/generatedManagerTypes'
type QueuedTask<T> = {
task: () => Promise<T>
onComplete?: () => void
}
type ManagerTaskHistory = Record<
string,
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
type ManagerWsTaskDoneMsg = components['schemas']['MessageTaskDone']
type ManagerWsTaskStartedMsg = components['schemas']['MessageTaskStarted']
type QueueTaskItem = components['schemas']['QueueTaskItem']
type HistoryTaskItem = components['schemas']['TaskHistoryItem']
type Task = QueueTaskItem | HistoryTaskItem
const MANAGER_WS_MSG_TYPE = 'cm-queue-status'
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'
const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
export const useManagerQueue = () => {
const clientQueueItems = ref<QueuedTask<unknown>[]>([])
const clientQueueLength = computed(() => clientQueueItems.value.length)
const onCompletedQueue = ref<((() => void) | undefined)[]>([])
const onCompleteWaitingCount = ref(0)
const uncompletedCount = computed(
() => clientQueueLength.value + onCompleteWaitingCount.value
export const useManagerQueue = (
taskHistory: Ref<ManagerTaskHistory>,
taskQueue: Ref<ManagerTaskQueue>,
installedPacks: Ref<Record<string, any>>
) => {
const { showManagerProgressDialog } = useDialogService()
// Task queue state (read-only from server)
const maxHistoryItems = ref(64)
const isLoading = ref(false)
const isProcessing = ref(false)
// Computed values
const currentQueueLength = computed(
() =>
taskQueue.value.running_queue.length +
taskQueue.value.pending_queue.length
)
const serverQueueStatus = ref<ManagerWsQueueStatus>(ManagerWsQueueStatus.DONE)
const isServerIdle = computed(
() => serverQueueStatus.value === ManagerWsQueueStatus.DONE
)
/**
* Update the processing state based on the current queue length.
* If the queue is empty, or all tasks in the queue are associated
* with different clients, then this client is not processing any tasks.
*/
const updateProcessingState = (): void => {
isProcessing.value = currentQueueLength.value > 0
}
const allTasksDone = computed(
() => isServerIdle.value && clientQueueLength.value === 0
)
const nextTaskReady = computed(
() => isServerIdle.value && clientQueueLength.value > 0
)
const allTasksDone = computed(() => currentQueueLength.value === 0)
const historyCount = computed(() => Object.keys(taskHistory.value).length)
const cleanupListener = useEventListener(
api,
MANAGER_WS_MSG_TYPE,
(event: CustomEvent<{ status: ManagerWsQueueStatus }>) => {
if (event?.type === MANAGER_WS_MSG_TYPE && event.detail?.status) {
serverQueueStatus.value = event.detail.status
/**
* Check if a task is associated with this client.
* Task can be from running queue, pending queue, or history.
* @param task - The task to check
* @returns True if the task belongs to this client
*/
const isTaskFromThisClient = (task: Task): boolean =>
task.client_id === app.api.clientId
/**
* Filter queue tasks by client id.
* Ensures that only tasks associated with this client are processed and
* added to client state.
* @param tasks - Array of queue tasks to filter
* @returns Filtered array containing only tasks from this client
*/
const filterQueueByClientId = (tasks: QueueTaskItem[]): QueueTaskItem[] =>
tasks.filter(isTaskFromThisClient)
/**
* Filter history tasks by client id using lodash pickBy for optimal performance.
* Returns a new object containing only tasks associated with this client.
* @param history - The history object to filter
* @returns Filtered history object containing only tasks from this client
*/
const filterHistoryByClientId = (history: ManagerTaskHistory) =>
pickBy(history, isTaskFromThisClient)
/**
* Update task queue and history state with filtered data from server.
* Ensures only tasks from this client are stored in local state.
* @param state - The task state message from the server
*/
const updateTaskState = (state: ManagerTaskQueue) => {
taskQueue.value.running_queue = filterQueueByClientId(state.running_queue)
taskQueue.value.pending_queue = filterQueueByClientId(state.pending_queue)
taskHistory.value = filterHistoryByClientId(state.history)
if (state.installed_packs) {
// The keys are 'cleaned' by stripping the version suffix.
// The pack object itself (the value) still contains the version info.
const packsWithCleanedKeys = mapKeys(
state.installed_packs,
(_value, key) => {
return key.split('@')[0]
}
)
installedPacks.value = packsWithCleanedKeys
}
updateProcessingState()
}
// WebSocket event listener for task done
const cleanupTaskDoneListener = useEventListener(
app.api,
MANAGER_WS_TASK_DONE_NAME,
(event: CustomEvent<ManagerWsTaskDoneMsg>) => {
if (event?.type === MANAGER_WS_TASK_DONE_NAME) {
const { state } = event.detail
updateTaskState(state)
}
}
)
const startNextTask = () => {
const nextTask = clientQueueItems.value.shift()
if (!nextTask) return
const { task, onComplete } = nextTask
if (onComplete) {
// Set the task's onComplete to be executed the next time the server is idle
onCompletedQueue.value.push(onComplete)
onCompleteWaitingCount.value++
}
task().catch((e) => {
const message = `Error enqueuing task for ComfyUI Manager: ${e}`
console.error(message)
})
}
const enqueueTask = <T>(task: QueuedTask<T>): void => {
clientQueueItems.value.push(task)
}
const clearQueue = () => {
clientQueueItems.value = []
onCompletedQueue.value = []
onCompleteWaitingCount.value = 0
}
const cleanup = () => {
clearQueue()
cleanupListener()
}
whenever(nextTaskReady, startNextTask)
whenever(isServerIdle, () => {
if (onCompletedQueue.value?.length) {
while (
onCompleteWaitingCount.value > 0 &&
onCompletedQueue.value.length > 0
) {
const onComplete = onCompletedQueue.value.shift()
onComplete?.()
onCompleteWaitingCount.value--
// WebSocket event listener for task started
const cleanupTaskStartedListener = useEventListener(
app.api,
MANAGER_WS_TASK_STARTED_NAME,
(event: CustomEvent<ManagerWsTaskStartedMsg>) => {
if (event?.type === MANAGER_WS_TASK_STARTED_NAME) {
const { state } = event.detail
updateTaskState(state)
}
}
})
)
whenever(currentQueueLength, () => showManagerProgressDialog())
const stopListening = () => {
cleanupTaskDoneListener()
cleanupTaskStartedListener()
}
return {
allTasksDone,
statusMessage: readonly(serverQueueStatus),
queueLength: clientQueueLength,
uncompletedCount,
// Queue state (read-only from server)
taskHistory,
taskQueue,
maxHistoryItems,
isLoading,
enqueueTask,
clearQueue,
cleanup
// Computed state
allTasksDone,
isProcessing,
queueLength: currentQueueLength,
historyCount,
// Actions
stopListening
}
}

View File

@@ -1,24 +1,34 @@
import { useEventListener } from '@vueuse/core'
import { onUnmounted, ref } from 'vue'
import { ref } from 'vue'
import { LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { components } from '@/types/generatedManagerTypes'
const LOGS_MESSAGE_TYPE = 'logs'
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'
const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
type ManagerWsTaskDoneMsg = components['schemas']['MessageTaskDone']
type ManagerWsTaskStartedMsg = components['schemas']['MessageTaskStarted']
interface UseServerLogsOptions {
ui_id: string
immediate?: boolean
messageFilter?: (message: string) => boolean
}
export const useServerLogs = (options: UseServerLogsOptions = {}) => {
export const useServerLogs = (options: UseServerLogsOptions) => {
const {
immediate = false,
messageFilter = (msg: string) => Boolean(msg.trim())
} = options
const logs = ref<string[]>([])
let stop: ReturnType<typeof useEventListener> | null = null
const isTaskStarted = ref(false)
let stopLogs: ReturnType<typeof useEventListener> | null = null
let stopTaskDone: ReturnType<typeof useEventListener> | null = null
let stopTaskStarted: ReturnType<typeof useEventListener> | null = null
const isValidLogEvent = (event: CustomEvent<LogsWsMessage>) =>
event?.type === LOGS_MESSAGE_TYPE && event.detail?.entries?.length > 0
@@ -27,34 +37,81 @@ export const useServerLogs = (options: UseServerLogsOptions = {}) => {
event.detail.entries.map((e) => e.m).filter(messageFilter)
const handleLogMessage = (event: CustomEvent<LogsWsMessage>) => {
// Only capture logs if this task has started
if (!isTaskStarted.value) return
if (isValidLogEvent(event)) {
logs.value.push(...parseLogMessage(event))
const messages = parseLogMessage(event)
if (messages.length > 0) {
logs.value.push(...messages)
}
}
}
const start = async () => {
const handleTaskStarted = (event: CustomEvent<ManagerWsTaskStartedMsg>) => {
if (event?.type === MANAGER_WS_TASK_STARTED_NAME) {
// Check if this is our task starting
const isOurTask = event.detail.ui_id === options.ui_id
if (isOurTask) {
isTaskStarted.value = true
void stopTaskStarted?.()
}
}
}
const handleTaskDone = (event: CustomEvent<ManagerWsTaskDoneMsg>) => {
if (event?.type === MANAGER_WS_TASK_DONE_NAME) {
const { state } = event.detail
// Check if our task is now in the history (completed)
const isOurTaskDone = state.history[options.ui_id]
if (isOurTaskDone) {
isTaskStarted.value = false
void stopListening()
}
}
}
const startListening = async () => {
await api.subscribeLogs(true)
stop = useEventListener(api, LOGS_MESSAGE_TYPE, handleLogMessage)
stopLogs = useEventListener(api, LOGS_MESSAGE_TYPE, handleLogMessage)
stopTaskStarted = useEventListener(
api,
MANAGER_WS_TASK_STARTED_NAME,
handleTaskStarted
)
stopTaskDone = useEventListener(
api,
MANAGER_WS_TASK_DONE_NAME,
handleTaskDone
)
}
const stopListening = async () => {
stop?.()
stop = null
await api.subscribeLogs(false)
stopLogs?.()
stopTaskStarted?.()
stopTaskDone?.()
stopLogs = null
stopTaskStarted = null
stopTaskDone = null
// TODO: move subscribe/unsubscribe logs to useManagerQueue. Subscribe when task starts if not already subscribed.
// Unsubscribe ONLY when there are no tasks running or queued up and the only remaining task finishes.
// await api.subscribeLogs(false)
}
if (immediate) {
void start()
void startListening()
}
onUnmounted(async () => {
const cleanup = async () => {
await stopListening()
logs.value = []
})
isTaskStarted.value = false
}
return {
logs,
startListening: start,
stopListening
startListening,
stopListening,
cleanup
}
}

View File

@@ -0,0 +1,3 @@
{
"supports_preview_metadata": false
}

View File

@@ -11,9 +11,24 @@ export const CORE_MENU_COMMANDS = [
]
],
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[
['Edit'],
[
'Comfy.RefreshNodeDefinitions',
'Comfy.Memory.UnloadModels',
'Comfy.Memory.UnloadModelsAndExecutionCache'
]
],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[
['Manager'],
[
'Comfy.Manager.CustomNodesManager.ShowCustomNodesMenu',
'Comfy.Manager.ShowMissingPacks',
'Comfy.Manager.ShowUpdateAvailablePacks'
]
],
[
['Help'],
[

View File

@@ -290,6 +290,7 @@ export const CORE_SETTINGS: SettingParams[] = [
options: [
{ value: 'en', text: 'English' },
{ value: 'zh', text: '中文' },
{ value: 'zh-TW', text: '繁體中文' },
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
@@ -330,6 +331,14 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.20.3'
},
{
id: 'Comfy.Notification.ShowVersionUpdates',
category: ['Comfy', 'Notification Preferences'],
name: 'Show version updates',
tooltip: 'Show updates for new models, and major new features.',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.ConfirmClear',
category: ['Comfy', 'Workflow', 'ConfirmClear'],
@@ -431,6 +440,8 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Use new menu',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom'],
tooltip:
'Menu bar position. On mobile devices, the menu is always shown at the top.',
migrateDeprecatedValue: (value: string) => {
// Floating is now supported by dragging the docked actionbar off.
if (value === 'Floating') {
@@ -873,5 +884,12 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Release seen timestamp',
type: 'hidden',
defaultValue: 0
},
{
id: 'Comfy.Memory.AllowManualUnload',
name: 'Allow manual unload of models and execution cache via user command',
type: 'hidden',
defaultValue: true,
versionAdded: '1.18.0'
}
]

View File

@@ -1,5 +1,11 @@
import { LiteGraph } from '@comfyorg/litegraph'
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
import {
type ExecutableLGraphNode,
type ExecutionId,
LGraphNode,
LiteGraph,
SubgraphNode
} from '@comfyorg/litegraph'
import { type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
import { t } from '@/i18n'
import {
@@ -13,6 +19,8 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { ComfyExtension } from '@/types/comfy'
import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO'
import { GROUP } from '@/utils/executableGroupNodeDto'
import { deserialiseAndCreate, serialise } from '@/utils/vintageClipboard'
import { api } from '../../scripts/api'
@@ -26,8 +34,6 @@ type GroupNodeWorkflowData = {
nodes: ComfyNode[]
}
const GROUP = Symbol()
// v1 Prefix + Separator: workflow/
// v2 Prefix + Separator: workflow> (ComfyUI_frontend v1.2.63)
const PREFIX = 'workflow'
@@ -813,6 +819,7 @@ export class GroupNodeHandler {
innerNodeIndex++
) {
const innerNode = this.innerNodes[innerNodeIndex]
innerNode.graph ??= this.node.graph
for (const w of innerNode.widgets ?? []) {
if (w.type === 'converted-widget') {
@@ -899,7 +906,20 @@ export class GroupNodeHandler {
return link
}
this.node.getInnerNodes = () => {
/** @internal Used to flatten the subgraph before execution. Recursive; call with no args. */
this.node.getInnerNodes = (
computedNodeDtos: Map<ExecutionId, ExecutableLGraphNode>,
/** The path of subgraph node IDs. */
subgraphNodePath: readonly NodeId[] = [],
/** The list of nodes to add to. */
nodes: ExecutableLGraphNode[] = [],
/** The set of visited nodes. */
visited = new Set<LGraphNode>()
): ExecutableLGraphNode[] => {
if (visited.has(this.node))
throw new Error('RecursionError: while flattening subgraph')
visited.add(this.node)
if (!this.innerNodes) {
// @ts-expect-error fixme ts strict error
this.node.setInnerNodes(
@@ -910,6 +930,8 @@ export class GroupNodeHandler {
innerNode.configure(n)
// @ts-expect-error fixme ts strict error
innerNode.id = `${this.node.id}:${i}`
// @ts-expect-error fixme ts strict error
innerNode.graph = this.node.graph
return innerNode
})
)
@@ -917,7 +939,31 @@ export class GroupNodeHandler {
this.updateInnerWidgets()
return this.innerNodes
const subgraphInstanceIdPath = [...subgraphNodePath, this.node.id]
// Assertion: Deprecated, does not matter.
const subgraphNode = (this.node.graph?.getNodeById(
subgraphNodePath.at(-1)
) ?? undefined) as SubgraphNode | undefined
for (const node of this.innerNodes) {
node.graph ??= this.node.graph
// Create minimal DTOs rather than cloning the node
const currentId = String(node.id)
node.id = currentId.split(':').at(-1)
const aVeryRealNode = new ExecutableGroupNodeChildDTO(
node,
subgraphInstanceIdPath,
computedNodeDtos,
subgraphNode
)
node.id = currentId
aVeryRealNode.groupNodeHandler = this
nodes.push(aVeryRealNode)
}
return nodes
}
// @ts-expect-error fixme ts strict error
@@ -1503,6 +1549,9 @@ export class GroupNodeHandler {
this.linkOutputs(node, i)
app.graph.remove(node)
// Set internal ID to what is expected after workflow is reloaded
node.id = `${this.node.id}:${i}`
}
this.linkInputs()
@@ -1608,8 +1657,14 @@ async function convertSelectedNodesToGroupNode() {
if (nodes.length === 1) {
throw new Error('Please select multiple nodes to convert to group node')
}
if (nodes.some((n) => GroupNodeHandler.isGroupNode(n))) {
throw new Error('Selected nodes contain a group node')
for (const node of nodes) {
if (node instanceof SubgraphNode) {
throw new Error('Selected nodes contain a subgraph node')
}
if (GroupNodeHandler.isGroupNode(node)) {
throw new Error('Selected nodes contain a group node')
}
}
return await GroupNodeHandler.fromNodes(nodes)
}

View File

@@ -21,8 +21,6 @@ async function handleModelUpload(files: FileList, node: any) {
(w: any) => w.name === 'model_file'
) as IStringWidget
node.properties['Texture'] = undefined
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
@@ -215,8 +213,6 @@ useExtensionService().registerExtension({
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = ''
node.properties['Texture'] = undefined
}
})

View File

@@ -160,12 +160,6 @@ class Load3DConfiguration {
this.load3d.setEdgeThreshold(edgeThreshold)
const texturePath = this.load3d.loadNodeProperty('Texture', null)
if (texturePath) {
await this.load3d.applyTexture(texturePath)
}
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
this.load3d.setCameraState(cameraState)

View File

@@ -293,23 +293,6 @@ class Load3d {
}
}
async applyTexture(texturePath: string): Promise<void> {
if (!this.modelManager.currentModel) {
throw new Error('No model to apply texture to')
}
this.eventManager.emitEvent('textureLoadingStart', null)
try {
await this.modelManager.applyTexture(texturePath)
} catch (error) {
console.error('Error applying texture:', error)
throw error
} finally {
this.eventManager.emitEvent('textureLoadingEnd', null)
}
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)

View File

@@ -5,7 +5,6 @@ import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeome
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils'
import Load3dUtils from './Load3dUtils'
import { ColoredShadowMaterial } from './conditional-lines/ColoredShadowMaterial'
import { ConditionalEdgesGeometry } from './conditional-lines/ConditionalEdgesGeometry'
import { ConditionalEdgesShader } from './conditional-lines/ConditionalEdgesShader.js'
@@ -135,66 +134,6 @@ export class ModelManager implements ModelManagerInterface {
})
}
async applyTexture(texturePath: string): Promise<void> {
if (!this.currentModel) {
throw new Error('No model available to apply texture to')
}
if (this.appliedTexture) {
this.appliedTexture.dispose()
}
try {
let imageUrl = Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(texturePath)
)
if (!imageUrl.startsWith('/api')) {
imageUrl = '/api' + imageUrl
}
this.appliedTexture = await new Promise<THREE.Texture>(
(resolve, reject) => {
this.textureLoader.load(
imageUrl,
(texture) => {
texture.colorSpace = THREE.SRGBColorSpace
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
resolve(texture)
},
undefined,
(error) => reject(error)
)
}
)
if (this.materialMode === 'original') {
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
const material = new THREE.MeshStandardMaterial({
map: this.appliedTexture,
metalness: 0.1,
roughness: 0.8,
side: THREE.DoubleSide
})
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = material
}
})
}
return Promise.resolve()
} catch (error) {
console.error('Error applying texture:', error)
return Promise.reject(error)
}
}
disposeLineartModel(): void {
this.disposeEdgesModel()
this.disposeShadowModel()

View File

@@ -24,6 +24,10 @@ import ruCommands from './locales/ru/commands.json'
import ru from './locales/ru/main.json'
import ruNodes from './locales/ru/nodeDefs.json'
import ruSettings from './locales/ru/settings.json'
import zhTWCommands from './locales/zh-TW/commands.json'
import zhTW from './locales/zh-TW/main.json'
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
import zhTWSettings from './locales/zh-TW/settings.json'
import zhCommands from './locales/zh/commands.json'
import zh from './locales/zh/main.json'
import zhNodes from './locales/zh/nodeDefs.json'
@@ -41,6 +45,7 @@ function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
const messages = {
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),

172
src/locales/CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,172 @@
# Contributing Translations to ComfyUI
## Quick Start for New Languages
1. **Let us know** - Open an issue or reach out on Discord to request a new language
2. **Get technical setup help** - We'll help configure the initial files or you can follow the technical process below
3. **Automatic translation** - Our CI system will generate translations using OpenAI when you create a PR
4. **Review and refine** - You can improve the auto-generated translations and become a maintainer for that language
## Technical Process (Confirmed Working)
### Prerequisites
- Node.js installed
- Git/GitHub knowledge
- OpenAI API key (optional - CI will handle translations)
### Step 1: Update Configuration Files
**Time required: ~10 minutes**
#### 1.1 Update `.i18nrc.cjs`
Add your language code to the `outputLocales` array:
```javascript
module.exports = defineConfig({
// ... existing config
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'], // Add your language here
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.
Note: For Traditional Chinese (Taiwan), use Taiwan-specific terminology and traditional characters.
`
});
```
#### 1.2 Update `src/constants/coreSettings.ts`
Add your language to the dropdown options:
```typescript
{
id: 'Comfy.Locale',
name: 'Language',
type: 'combo',
options: [
{ value: 'en', text: 'English' },
{ value: 'zh', text: '中文' },
{ value: 'zh-TW', text: '繁體中文 (台灣)' }, // Add your language here
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' }
],
defaultValue: () => navigator.language.split('-')[0] || 'en'
},
```
#### 1.3 Update `src/i18n.ts`
Add imports for your new language files:
```typescript
// Add these imports (replace zh-TW with your language code)
import zhTWCommands from './locales/zh-TW/commands.json'
import zhTW from './locales/zh-TW/main.json'
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
import zhTWSettings from './locales/zh-TW/settings.json'
// Add to the messages object
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings),
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings), // Add this line
// ... other languages
}
```
### Step 2: Generate Translation Files
#### Option A: Local Generation (Optional)
```bash
# Only if you have OpenAI API key configured
npm run locale
```
#### Option B: Let CI Handle It (Recommended)
- Create your PR with the configuration changes above
- Our GitHub CI will automatically generate translation files
- Empty JSON files are fine - they'll be populated by the workflow
### Step 3: Test Your Changes
```bash
npm run typecheck # Check for TypeScript errors
npm run dev # Start development server
```
**Testing checklist:**
- [ ] Language appears in ComfyUI Settings > Locale dropdown
- [ ] Can select the new language without errors
- [ ] Partial translations display correctly
- [ ] UI falls back to English for untranslated strings
- [ ] No console errors when switching languages
### Step 4: Submit PR
1. **Create PR** with your configuration changes
2. **CI will run** and automatically populate translation files
3. **Request review** from language maintainers: @Yorha4D @KarryCharon @DorotaLuna @shinshin86
4. **Get added to CODEOWNERS** as a reviewer for your language
## What Happens in CI
Our automated translation workflow:
1. **Collects strings**: Scans the UI for translatable text
2. **Updates English files**: Ensures all strings are captured
3. **Generates translations**: Uses OpenAI API to translate to all configured languages
4. **Commits back**: Automatically updates your PR with complete translations
## File Structure
Each language has 4 translation files:
- `main.json` - Main UI text (~2000+ entries)
- `commands.json` - Command descriptions (~200+ entries)
- `settings.json` - Settings panel (~400+ entries)
- `nodeDefs.json` - Node definitions (~varies based on installed nodes)
## Translation Quality
- **Auto-translations are high quality** but may need refinement
- **Technical terms** are preserved (flux, photomaker, clip, vae, etc.)
- **Context-aware** translations based on UI usage
- **Native speaker review** is encouraged for quality improvements
## Common Issues & Solutions
### Issue: TypeScript errors on imports
**Solution**: Ensure your language code matches exactly in all three files
### Issue: Empty translation files
**Solution**: This is normal - CI will populate them when you create a PR
### Issue: Language not appearing in dropdown
**Solution**: Check that the language code in `coreSettings.ts` matches your other files exactly
### Issue: Rate limits during local translation
**Solution**: This is expected - let CI handle the translation generation
## Regional Variants
For regional variants (like zh-TW for Taiwan), use:
- **Language-region codes**: `zh-TW`, `pt-BR`, `en-US`
- **Specific terminology**: Add region-specific context to the reference string
- **Native display names**: Use the local language name in the dropdown
## Getting Help
- **Tag translation maintainers**: @Yorha4D @KarryCharon @DorotaLuna @shinshin86
- **Check existing language PRs** for examples
- **Open an issue** describing your language addition request
- **Reference this tested process** - we've confirmed it works!
## Becoming a Language Maintainer
After your language is added:
1. **Get added to CODEOWNERS** for your language files
2. **Review future PRs** affecting your language
3. **Coordinate with other native speakers** for quality improvements
4. **Help maintain translations** as the UI evolves
---
*This process was tested and confirmed working with Traditional Chinese (Taiwan) addition.*

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