Compare commits

..

6 Commits

Author SHA1 Message Date
bymyself
548d38fe4b Fix newUserService test after console.log removal
Update test to verify new user status without relying on console.log
2025-07-04 16:22:11 -07:00
bymyself
df15572562 Remove console.log from newUserService 2025-07-04 16:01:39 -07:00
bymyself
7cdd9c18df Clarify Comfy.InstalledVersion setting description 2025-07-04 14:36:51 -07:00
Terry Jia
c92288ecc1 improve code 2025-07-04 08:54:30 -04:00
Terry Jia
dbaecdebba add newUserService and unit tests 2025-07-03 21:30:51 -04:00
Terry Jia
f063eeb4be add installedVersion 2025-07-02 22:54:12 -04:00
186 changed files with 3761 additions and 30005 deletions

View File

@@ -1,708 +0,0 @@
# 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

@@ -1,625 +0,0 @@
# 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

@@ -1,222 +0,0 @@
# 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
View File

@@ -1,36 +0,0 @@
# 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`.

View File

@@ -1,75 +0,0 @@
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 }}

View File

@@ -1,154 +0,0 @@
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

View File

@@ -1,149 +0,0 @@
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

@@ -1,9 +1,5 @@
if [[ "$OS" == "Windows_NT" ]]; then
npx.cmd lint-staged
# Check for unused i18n keys in staged files
npx.cmd tsx scripts/check-unused-i18n-keys.ts
else
npx lint-staged
# Check for unused i18n keys in staged files
npx tsx scripts/check-unused-i18n-keys.ts
fi

View File

@@ -9,10 +9,9 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
outputLocales: ['zh', '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; v20/v22 strongly recommended) and npm
- Node.js (v16 or later) 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 v22 installed. Then, set up the Chromium test driver:
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
```bash
npx playwright install chromium --with-deps
```

View File

@@ -1,716 +0,0 @@
{
"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

@@ -1,382 +0,0 @@
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: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -130,239 +130,4 @@ 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

@@ -1,82 +0,0 @@
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: 77 KiB

After

Width:  |  Height:  |  Size: 78 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

@@ -1,289 +0,0 @@
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.page.waitForTimeout(500)
await comfyPage.nextFrame()
const refreshedComboValues = await getComboValues()
expect(refreshedComboValues).not.toEqual(initialComboValues)

View File

@@ -0,0 +1,59 @@
import { Plugin } from 'vite'
/**
* Vite plugin that adds an alias export for Vue's createBaseVNode as createElementVNode.
*
* This plugin addresses compatibility issues where some components or libraries
* might be using the older createElementVNode function name instead of createBaseVNode.
* It modifies the Vue vendor chunk during build to add the alias export.
*
* @returns {Plugin} A Vite plugin that modifies the Vue vendor chunk exports
*/
export function addElementVnodeExportPlugin(): Plugin {
return {
name: 'add-element-vnode-export-plugin',
renderChunk(code, chunk, _options) {
if (chunk.name.startsWith('vendor-vue')) {
const exportRegex = /(export\s*\{)([^}]*)(\}\s*;?\s*)$/
const match = code.match(exportRegex)
if (match) {
const existingExports = match[2].trim()
const exportsArray = existingExports
.split(',')
.map((e) => e.trim())
.filter(Boolean)
const hasCreateBaseVNode = exportsArray.some((e) =>
e.startsWith('createBaseVNode')
)
const hasCreateElementVNode = exportsArray.some((e) =>
e.includes('createElementVNode')
)
if (hasCreateBaseVNode && !hasCreateElementVNode) {
const newExportStatement = `${match[1]} ${existingExports ? existingExports + ',' : ''} createBaseVNode as createElementVNode ${match[3]}`
const newCode = code.replace(exportRegex, newExportStatement)
console.log(
`[add-element-vnode-export-plugin] Added 'createBaseVNode as createElementVNode' export to vendor-vue chunk.`
)
return { code: newCode, map: null }
} else if (!hasCreateBaseVNode) {
console.warn(
`[add-element-vnode-export-plugin] Warning: 'createBaseVNode' not found in exports of vendor-vue chunk. Cannot add alias.`
)
}
} else {
console.warn(
`[add-element-vnode-export-plugin] Warning: Could not find expected export block format in vendor-vue chunk.`
)
}
}
return null
}
}
}

View File

@@ -1,24 +1,9 @@
import glob from 'fast-glob'
import fs from 'fs-extra'
import { dirname, join } from 'node:path'
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
import type { OutputOptions } from 'rollup'
import { HtmlTagDescriptor, Plugin } from 'vite'
interface ImportMapSource {
interface VendorLibrary {
name: string
pattern: string | RegExp
entry: string
recursiveDependence?: boolean
override?: Record<string, Partial<ImportMapSource>>
}
const parseDeps = (root: string, pkg: string) => {
const pkgPath = join(root, 'node_modules', pkg, 'package.json')
if (fs.existsSync(pkgPath)) {
const content = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(content)
return Object.keys(pkg.dependencies || {})
}
return []
pattern: RegExp
}
/**
@@ -38,89 +23,53 @@ const parseDeps = (root: string, pkg: string) => {
* @returns {Plugin} A Vite plugin that generates and injects an import map
*/
export function generateImportMapPlugin(
importMapSources: ImportMapSource[]
vendorLibraries: VendorLibrary[]
): Plugin {
const importMapEntries: Record<string, string> = {}
const resolvedImportMapSources: Map<string, ImportMapSource> = new Map()
const assetDir = 'assets/lib'
let root: string
return {
name: 'generate-import-map-plugin',
// Configure manual chunks during the build process
configResolved(config) {
root = config.root
if (config.build) {
// Ensure rollupOptions exists
if (!config.build.rollupOptions) {
config.build.rollupOptions = {}
}
for (const source of importMapSources) {
resolvedImportMapSources.set(source.name, source)
if (source.recursiveDependence) {
const deps = parseDeps(root, source.name)
while (deps.length) {
const dep = deps.shift()!
const depSource = Object.assign({}, source, {
name: dep,
pattern: dep,
...source.override?.[dep]
})
resolvedImportMapSources.set(depSource.name, depSource)
const _deps = parseDeps(root, depSource.name)
deps.unshift(..._deps)
const outputOptions: OutputOptions = {
manualChunks: (id: string) => {
for (const lib of vendorLibraries) {
if (lib.pattern.test(id)) {
return `vendor-${lib.name}`
}
}
}
return null
},
// Disable minification of internal exports to preserve function names
minifyInternalExports: false
}
const external: (string | RegExp)[] = []
for (const [, source] of resolvedImportMapSources) {
external.push(source.pattern)
}
config.build.rollupOptions.external = external
config.build.rollupOptions.output = outputOptions
}
},
generateBundle(_options) {
for (const [, source] of resolvedImportMapSources) {
if (source.entry) {
const moduleFile = join(source.name, source.entry)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
generateBundle(_options, bundle) {
for (const fileName in bundle) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && !chunk.isEntry) {
// Find matching vendor library by chunk name
const vendorLib = vendorLibraries.find(
(lib) => chunk.name === `vendor-${lib.name}`
)
importMapEntries[source.name] =
'./' + normalizePath(join(assetDir, moduleFile))
if (vendorLib) {
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
importMapEntries[vendorLib.name] = relativePath
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
}
if (source.recursiveDependence) {
const files = glob.sync(['**/*.{js,mjs}'], {
cwd: join(root, 'node_modules', source.name)
})
for (const file of files) {
const moduleFile = join(source.name, file)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
importMapEntries[normalizePath(join(source.name, dirname(file)))] =
'./' + normalizePath(join(assetDir, moduleFile))
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
console.log(
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
)
}
}
}

View File

@@ -1,2 +1,3 @@
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
export { comfyAPIPlugin } from './comfyAPIPlugin'
export { generateImportMapPlugin } from './generateImportMapPlugin'

180
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.1",
"version": "1.24.0-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.1",
"version": "1.24.0-0",
"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.13",
"@comfyorg/litegraph": "^0.16.4",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -40,7 +40,6 @@
"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",
@@ -63,7 +62,6 @@
"@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",
@@ -559,16 +557,6 @@
"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",
@@ -613,16 +601,6 @@
"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",
@@ -644,16 +622,6 @@
"@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",
@@ -981,9 +949,10 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.16.13",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.13.tgz",
"integrity": "sha512-Y65YDyX/X/gqxtEa3IS5jb/1UUETCZXnrxM7YwSTIdzq3SHqwnDNstVBvhe+MtQUYRYqURzlg6sSrCO/BoKwkQ=="
"version": "0.16.4",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.4.tgz",
"integrity": "sha512-g4zhMxiCoE/WMap65fMNncQoTzn8ebMojjdv/b3Pc5RYmPQrwA32SDAvT/ndpkUonkOYmT9DgVmTxQv7LDQ6tA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
@@ -4554,13 +4523,6 @@
"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",
@@ -4792,6 +4754,19 @@
"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",
@@ -6562,6 +6537,19 @@
"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",
@@ -7461,6 +7449,19 @@
"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",
@@ -7851,6 +7852,19 @@
"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",
@@ -10712,6 +10726,19 @@
"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",
@@ -12661,6 +12688,19 @@
"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",
@@ -14303,15 +14343,12 @@
"dev": true
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
@@ -16170,6 +16207,19 @@
"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",
@@ -16989,6 +17039,19 @@
"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",
@@ -17041,6 +17104,19 @@
"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.1",
"version": "1.24.0-0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -40,7 +40,6 @@
"@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",
@@ -78,7 +77,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.13",
"@comfyorg/litegraph": "^0.16.4",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -106,7 +105,6 @@
"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

@@ -1,192 +0,0 @@
#!/usr/bin/env tsx
import { execSync } from 'child_process'
import * as fs from 'fs'
import { globSync } from 'glob'
interface LocaleData {
[key: string]: any
}
// Configuration
const SOURCE_PATTERNS = ['src/**/*.{js,ts,vue}', '!src/locales/**/*']
const IGNORE_PATTERNS = [
// Keys that might be dynamically constructed
/^commands\./, // Command definitions are loaded dynamically
/^settings\..*\.options\./, // Setting options are rendered dynamically
/^nodeDefs\./, // Node definitions are loaded from backend
/^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
// 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
function getStagedLocaleFiles(): string[] {
try {
const output = execSync('git diff --cached --name-only --diff-filter=AM', {
encoding: 'utf-8'
})
return output
.split('\n')
.filter(
(file) => file.startsWith('src/locales/') && file.endsWith('.json')
)
} catch {
return []
}
}
// Extract all keys from a nested object
function extractKeys(obj: any, prefix = ''): string[] {
const keys: string[] = []
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
keys.push(...extractKeys(value, fullKey))
} else {
keys.push(fullKey)
}
}
return keys
}
// Get new keys added in staged files
function getNewKeysFromStagedFiles(stagedFiles: string[]): Set<string> {
const newKeys = new Set<string>()
for (const file of stagedFiles) {
try {
// Get the staged content
const stagedContent = execSync(`git show :${file}`, { encoding: 'utf-8' })
const stagedData: LocaleData = JSON.parse(stagedContent)
const stagedKeys = new Set(extractKeys(stagedData))
// Get the current HEAD content (if file exists)
let headKeys = new Set<string>()
try {
const headContent = execSync(`git show HEAD:${file}`, {
encoding: 'utf-8'
})
const headData: LocaleData = JSON.parse(headContent)
headKeys = new Set(extractKeys(headData))
} catch {
// File is new, all keys are new
}
// Find keys that are in staged but not in HEAD
stagedKeys.forEach((key) => {
if (!headKeys.has(key)) {
newKeys.add(key)
}
})
} catch (error) {
console.error(`Error processing ${file}:`, error)
}
}
return newKeys
}
// Check if a key should be ignored
function shouldIgnoreKey(key: string): boolean {
return IGNORE_PATTERNS.some((pattern) => pattern.test(key))
}
// 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*['"\`]${escapedKey}['"\`]`, 'g'),
// With namespace: $t('g.key'), t('namespace.key')
new RegExp(`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${escapedLastPart}['"\`]`, 'g'),
// Dynamic keys might reference parts of the key
new RegExp(`['"\`]${escapedKey}['"\`]`, 'g')
]
for (const file of sourceFiles) {
const content = fs.readFileSync(file, 'utf-8')
for (const pattern of patterns) {
if (pattern.test(content)) {
return true
}
}
}
return false
}
// Main function
async function checkNewUnusedKeys() {
const stagedLocaleFiles = getStagedLocaleFiles()
if (stagedLocaleFiles.length === 0) {
// No locale files staged, nothing to check
process.exit(0)
}
// Get all new keys from staged files
const newKeys = getNewKeysFromStagedFiles(stagedLocaleFiles)
if (newKeys.size === 0) {
// Silent success - no output needed
process.exit(0)
}
// Get all source files
const sourceFiles = globSync(SOURCE_PATTERNS)
// Check each new key
const unusedNewKeys: string[] = []
newKeys.forEach((key) => {
if (!shouldIgnoreKey(key) && !isKeyUsed(key, sourceFiles)) {
unusedNewKeys.push(key)
}
})
// Report results
if (unusedNewKeys.length > 0) {
console.log('\n⚠ Warning: Found unused NEW i18n keys:\n')
for (const key of unusedNewKeys.sort()) {
console.log(` - ${key}`)
}
console.log(`\n✨ Total unused new keys: ${unusedNewKeys.length}`)
console.log(
'\nThese keys were added but are not used anywhere in the codebase.'
)
console.log('Consider using them or removing them in a future update.')
// Changed from process.exit(1) to process.exit(0) for warning only
process.exit(0)
} else {
// Silent success - no output needed
}
}
// Run the check
checkNewUnusedKeys().catch((err) => {
console.error('Error checking unused keys:', err)
process.exit(1)
})

View File

@@ -15,14 +15,12 @@ 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
@@ -49,9 +47,5 @@ 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

@@ -1,130 +0,0 @@
<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" v-bind="item.footerProps" />
<component :is="item.footerComponent" />
</template>
</Dialog>
</template>

View File

@@ -31,7 +31,7 @@
</div>
</template>
</ListBox>
<div v-if="!isLegacyManager" class="flex justify-end py-3">
<div v-if="isManagerInstalled" 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, onMounted, ref } from 'vue'
import { computed } 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,11 +60,22 @@ 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()
const isLegacyManager = ref(false)
// 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 uniqueNodes = computed(() => {
const seenTypes = new Set()
@@ -92,13 +103,6 @@ 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,20 +30,11 @@ const defaultMockTaskLogs = [
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs],
succeededTasksLogs: [...defaultMockTaskLogs],
failedTasksLogs: [...defaultMockTaskLogs],
managerQueue: { historyCount: 2 },
isLoading: false
taskLogs: [...defaultMockTaskLogs]
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
activeTabIndex: 0,
getActiveTabIndex: vi.fn(() => 0),
setActiveTabIndex: vi.fn(),
toggle: vi.fn(),
collapse: mockCollapse,
expand: vi.fn()
collapse: mockCollapse
}))
}))

View File

@@ -18,7 +18,7 @@
'max-h-0': !isExpanded
}"
>
<div v-for="(log, index) in focusedLogs" :key="index">
<div v-for="(panel, index) in taskPanels" :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>{{ log.taskName }}</span>
<span>{{ panel.taskName }}</span>
<span class="text-muted">
{{
isInProgress(index)
@@ -52,24 +52,24 @@
</template>
<div
:ref="
index === focusedLogs.length - 1
index === taskPanels.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== focusedLogs.length - 1,
'flex-grow': index === focusedLogs.length - 1
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(logLine, logIndex) in log.logs"
v-for="(log, logIndex) in panel.logs"
:key="logIndex"
class="text-neutral-400 dark-theme:text-muted"
>
<pre class="whitespace-pre-wrap break-words">{{ logLine }}</pre>
<pre class="whitespace-pre-wrap break-words">{{ log }}</pre>
</div>
</div>
</div>
@@ -90,23 +90,17 @@ import {
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const comfyManagerStore = useComfyManagerStore()
const { taskLogs } = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const managerStore = useComfyManagerStore()
const isInProgress = (index: number) =>
index === comfyManagerStore.managerQueue.historyCount - 1 &&
comfyManagerStore.isLoading
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
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]
@@ -121,7 +115,7 @@ const { y: scrollY } = useScroll(sectionsContainerRef, {
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false

View File

@@ -26,27 +26,6 @@
}"
>
<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"
@@ -55,7 +34,6 @@
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
:is-update-available-tab="isUpdateAvailableTab"
/>
<div class="flex-1 overflow-auto">
<div
@@ -91,9 +69,7 @@
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="
(event: MouseEvent) => selectNodePack(item, event)
"
@click.stop="(event) => selectNodePack(item, event)"
/>
</template>
</VirtualGrid>
@@ -125,8 +101,7 @@ import {
onMounted,
onUnmounted,
ref,
watch,
watchEffect
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -144,7 +119,6 @@ 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'
@@ -159,7 +133,6 @@ const { initialTab } = defineProps<{
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const conflictBannerState = useConflictBannerState()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
@@ -176,9 +149,6 @@ 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' },
@@ -342,13 +312,6 @@ 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:
@@ -477,7 +440,6 @@ 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) {
@@ -510,14 +472,6 @@ 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,16 +4,6 @@
<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

@@ -1,163 +0,0 @@
<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

@@ -1,59 +0,0 @@
<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

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

View File

@@ -6,18 +6,11 @@ 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',
@@ -127,7 +120,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('nightly')
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
@@ -141,7 +134,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('nightly')
expect(badge.find('span').text()).toBe(SelectedVersion.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 py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
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 }"
aria-haspopup="true"
role="button"
tabindex="0"
@@ -12,7 +12,8 @@
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600 text-xs"
class="pi pi-arrow-circle-up text-blue-600"
style="font-size: 8px"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right" style="font-size: 8px" />
@@ -21,7 +22,7 @@
<Popover
ref="popoverRef"
:pt="{
content: { class: 'p-0 shadow-lg' }
content: { class: 'px-0' }
}"
>
<PackVersionSelectorPopover
@@ -41,7 +42,8 @@ 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 type { components } from '@/types/comfyRegistryTypes'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
@@ -62,11 +64,11 @@ const popoverRef = ref()
const managerStore = useComfyManagerStore()
const installedVersion = computed(() => {
if (!nodePack.id) return 'nightly'
if (!nodePack.id) return SelectedVersion.NIGHTLY
const version =
managerStore.installedPacks[nodePack.id]?.ver ??
nodePack.latest_version?.version ??
'nightly'
SelectedVersion.NIGHTLY
// If Git hash, truncate to 7 characters
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)

View File

@@ -3,32 +3,18 @@ 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'
// SelectedVersion is now using direct strings instead of enum
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Default mock versions for reference
const defaultMockVersions = [
{
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: '1.0.0', createdAt: '2023-01-01' },
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
]
@@ -36,24 +22,13 @@ const defaultMockVersions = [
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
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
latest_version: { version: '1.0.0' },
repository: 'https://github.com/user/repo'
}
// 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', () => ({
@@ -74,13 +49,6 @@ 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()
@@ -91,9 +59,6 @@ describe('PackVersionSelectorPopover', () => {
vi.clearAllMocks()
mockGetPackVersions.mockReset()
mockInstallPack.mockReset().mockResolvedValue(undefined)
mockCheckVersionCompatibility
.mockReset()
.mockReturnValue({ hasConflict: false, conflicts: [] })
})
const mountComponent = ({
@@ -113,12 +78,7 @@ describe('PackVersionSelectorPopover', () => {
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox,
VerifiedIcon,
Select
},
directives: {
tooltip: Tooltip
Listbox
}
}
})
@@ -160,15 +120,14 @@ describe('PackVersionSelectorPopover', () => {
const options = listbox.props('options')!
// Check that we have both special options and 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
expect(options.length).toBe(defaultMockVersions.length + 2) // 2 special options + version options
// Check that special options exist
expect(options.some((o) => o.value === 'nightly')).toBe(true)
expect(options.some((o) => o.value === 'latest')).toBe(true)
expect(options.some((o) => o.value === SelectedVersion.NIGHTLY)).toBe(true)
expect(options.some((o) => o.value === SelectedVersion.LATEST)).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
// Check that version options exist
expect(options.some((o) => o.value === '1.0.0')).toBe(true)
expect(options.some((o) => o.value === '0.9.0')).toBe(true)
expect(options.some((o) => o.value === '0.8.0')).toBe(true)
})
@@ -345,7 +304,7 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe('nightly')
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
@@ -366,360 +325,7 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
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)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
})
})
})

View File

@@ -1,10 +1,8 @@
<template>
<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 class="w-64 mt-2">
<span class="pl-3 text-muted text-md font-semibold opacity-70">
{{ $t('manager.selectVersion') }}
</span>
<div
v-if="isLoadingVersions || isQueueing"
class="text-center text-muted py-4 flex flex-col items-center"
@@ -25,44 +23,24 @@
v-model="selectedVersion"
option-label="label"
option-value="value"
:options="processedVersionOptions"
:options="versionOptions"
:highlight-on-select="false"
class="w-full max-h-[50vh] border-none shadow-none rounded-md"
:pt="{
listContainer: { class: 'scrollbar-hide' }
}"
class="my-3 w-full max-h-[50vh] border-none shadow-none"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<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>
<span>{{ slotProps.option.label }}</span>
<i
v-if="slotProps.option.isSelected"
v-if="selectedVersion === slotProps.option.value"
class="pi pi-check text-highlight"
/>
</div>
</template>
</Listbox>
<ContentDivider class="my-2" />
<div class="flex justify-end gap-2 py-1 px-3">
<div class="flex justify-end gap-2 p-1 px-3">
<Button
text
class="text-sm"
severity="secondary"
:label="$t('g.cancel')"
:disabled="isQueueing"
@@ -71,7 +49,7 @@
<Button
severity="secondary"
:label="$t('g.install')"
class="py-2.5 px-4 text-sm dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
class="py-3 px-4 dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
:disabled="isQueueing"
@click="handleSubmit"
/>
@@ -84,18 +62,19 @@ import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted, ref } from 'vue'
import { 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 type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
import {
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
const { nodePack } = defineProps<{
@@ -110,30 +89,27 @@ const emit = defineEmits<{
const { t } = useI18n()
const registryService = useComfyRegistryService()
const managerStore = useComfyManagerStore()
const { checkNodeCompatibility } = useConflictDetection()
const isQueueing = ref(false)
const selectedVersion = ref<string>('latest')
const selectedVersion = ref<string>(SelectedVersion.LATEST)
onMounted(() => {
const initialVersion = getInitialSelectedVersion() ?? 'latest'
const initialVersion = getInitialSelectedVersion() ?? SelectedVersion.LATEST
selectedVersion.value =
// Use NIGHTLY when version is a Git hash
isSemVer(initialVersion) ? initialVersion : 'nightly'
isSemVer(initialVersion) ? initialVersion : SelectedVersion.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
}
@@ -150,9 +126,6 @@ const versionOptions = ref<
}[]
>([])
// Store fetched versions with their full data
const fetchedVersions = ref<components['schemas']['NodeVersion'][]>([])
const isLoadingVersions = ref(false)
const onNodePackChange = async () => {
@@ -160,35 +133,26 @@ 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 && 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')
.filter((option) => option.value)
// Add Latest option
const defaultVersions = [
{
value: 'latest' as ManagerComponents['schemas']['SelectedVersion'],
label: latestLabel
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
}
]
// Add Nightly option if there is a non-empty `repository` field
if (nodePack.repository?.length) {
defaultVersions.push({
value: 'nightly' as ManagerComponents['schemas']['SelectedVersion'],
label: t('manager.nightlyVersion') // Keep as just "nightly" - no version number
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
})
}
@@ -208,95 +172,16 @@ 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: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: actualVersion
channel: ManagerChannel.DEFAULT,
mode: ManagerDatabaseSource.CACHE,
version: selectedVersion.value,
selected_version: selectedVersion.value
})
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,24 +1,18 @@
<template>
<Button
outlined
class="!m-0 p-0 max-w-[120px] rounded-lg text-gray-900 dark-theme:text-gray-50"
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
:class="[
variant === 'black'
? 'bg-neutral-900 text-white border-neutral-900'
: variant === 'red'
? 'border-red-500'
: 'border-neutral-700',
: 'border-neutral-700',
fullWidth ? 'w-full' : 'w-min-content'
]"
:disabled="loading"
v-bind="$attrs"
@click="onClick"
>
<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>
<span class="py-2 px-3 whitespace-nowrap">
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>
@@ -34,18 +28,15 @@ import Button from 'primevue/button'
const {
label,
loading = false,
loadingMessage,
fullWidth = false,
variant = 'default',
hasWarning = false
variant = 'default'
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
variant?: 'default' | 'black' | 'red'
hasWarning?: boolean
variant?: 'default' | 'black'
}>()
const emit = defineEmits<{

View File

@@ -11,22 +11,9 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'
// 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'
}
// Mock debounce to execute immediately
vi.mock('lodash', () => ({
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
}))
const mockNodePack = {

View File

@@ -1,29 +1,10 @@
<template>
<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>
<div class="flex items-center">
<ToggleSwitch
:model-value="isEnabled"
:disabled="isLoading"
aria-label="Enable or disable pack"
:class="{
'opacity-50 cursor-not-allowed': isLoading
}"
:pt="{
handle: {
class: 'bg-white'
}
}"
@update:model-value="handleToggleClick"
@update:model-value="onToggle"
/>
</div>
</template>
@@ -32,131 +13,64 @@
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 { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import {
InstallPackParams,
ManagerChannel,
SelectedVersion
} from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack, hasConflict } = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
hasConflict?: boolean
}>()
const { t } = useI18n()
const { isPackEnabled, enablePack, disablePack } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgeConflict, isConflictAcknowledged } =
useConflictAcknowledgment()
const { isPackEnabled, enablePack, disablePack, installedPacks } =
useComfyManagerStore()
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 = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for enabling')
}
return enablePack.call({
const handleEnable = () =>
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']),
version: version.value,
selected_version: version.value,
repository: nodePack.repository ?? '',
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
skip_post_install: false
channel: ManagerChannel.DEFAULT,
mode: 'default' as InstallPackParams['mode']
})
}
const handleDisable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for disabling')
}
return disablePack({
const handleDisable = () =>
disablePack({
id: nodePack.id,
version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
version: version.value
})
}
const performToggle = async (enable: boolean) => {
const handleToggle = async (enable: boolean) => {
if (isLoading.value) return
isLoading.value = true
try {
if (enable) {
await handleEnable()
} else {
await handleDisable()
}
} finally {
isLoading.value = false
if (enable) {
await handleEnable()
} else {
handleDisable()
}
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)
}
}
})
}
}
const onToggle = debounce(handleToggle, TOGGLE_DEBOUNCE_MS, { trailing: true })
</script>

View File

@@ -9,7 +9,6 @@
:variant="variant"
:loading="isInstalling"
:loading-message="$t('g.installing')"
:has-warning="hasConflict"
@action="installAllPacks"
@click="onClick"
/>
@@ -20,46 +19,43 @@ import { inject, ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import {
IsInstallingKey,
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} 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, hasConflict } = defineProps<{
const { nodePacks, variant, label } = 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 createPayload = (
installItem: NodePack
): ManagerComponents['schemas']['InstallPackParams'] => {
if (!installItem.id) {
throw new Error('Node ID is required for installation')
}
const managerStore = useComfyManagerStore()
const createPayload = (installItem: NodePack) => {
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
: installItem.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
? SelectedVersion.NIGHTLY
: installItem.latest_version?.version ?? SelectedVersion.LATEST
return {
id: installItem.id,
version: versionToInstall,
repository: installItem.repository ?? '',
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: versionToInstall
channel: ManagerChannel.DEV,
mode: ManagerDatabaseSource.CACHE,
selected_version: versionToInstall,
version: versionToInstall
}
}
@@ -69,40 +65,14 @@ const installPack = (item: NodePack) =>
const installAllPacks = async () => {
if (!nodePacks?.length) return
// 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
// }
isInstalling.value = true
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
// No conflicts or conflicts acknowledged - proceed with installation
await performInstallation(uninstalledPacks)
}
const performInstallation = async (packs: NodePack[]) => {
await Promise.all(packs.map(installPack))
await Promise.all(uninstalledPacks.map(installPack))
managerStore.installPack.clear()
}
</script>

View File

@@ -6,7 +6,7 @@
? $t('manager.uninstallSelected')
: $t('manager.uninstall')
"
variant="red"
severity="danger"
:loading-message="$t('manager.uninstalling')"
@action="uninstallItems"
/>
@@ -15,6 +15,7 @@
<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']
@@ -25,16 +26,16 @@ const { nodePacks } = defineProps<{
const managerStore = useComfyManagerStore()
const uninstallPack = (item: NodePack) => {
if (!item.id) {
throw new Error('Node ID is required for uninstallation')
const createPayload = (uninstallItem: NodePack): ManagerPackInfo => {
return {
id: uninstallItem.id,
version: uninstallItem.latest_version?.version
}
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

@@ -1,78 +0,0 @@
<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,14 +2,11 @@
<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]"
:has-conflict="hasCompatibilityIssues"
/>
<InfoPanelHeader :node-packs="[nodePack]" />
</div>
<div
ref="scrollContainer"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm scrollbar-hide"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar"
>
<div class="mb-6">
<MetadataRow
@@ -32,7 +29,6 @@
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
:has-compatibility-issues="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
@@ -40,11 +36,7 @@
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs
:node-pack="nodePack"
:has-compatibility-issues="hasCompatibilityIssues"
:conflict-result="conflictResult"
/>
<InfoTabs :node-pack="nodePack" />
</div>
</div>
</div>
@@ -67,12 +59,9 @@ 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
@@ -86,46 +75,18 @@ const { nodePack } = defineProps<{
const scrollContainer = ref<HTMLElement | null>(null)
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack.id))
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
whenever(isInstalled, () => {
isInstalling.value = false
})
const { checkNodeCompatibility } = useConflictDetection()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { isPackInstalled } = useComfyManagerStore()
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',
@@ -167,3 +128,17 @@ 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">
<div v-if="nodePacks?.length" class="flex flex-col items-center mb-6">
<slot name="thumbnail">
<PackIcon :node-pack="nodePacks[0]" width="204" height="106" />
<PackIcon :node-pack="nodePacks[0]" width="24" height="24" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">
<span class="inline-block text-base">{{ nodePacks[0].name }}</span>
{{ nodePacks[0].name }}
</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
@@ -18,16 +18,11 @@
v-bind="$attrs"
:node-packs="nodePacks"
/>
<PackInstallButton
v-else
v-bind="$attrs"
:node-packs="nodePacks"
:has-conflict="hasConflict"
/>
<PackInstallButton v-else v-bind="$attrs" :node-packs="nodePacks" />
</slot>
</div>
</div>
<div v-else class="flex flex-col items-center">
<div v-else class="flex flex-col items-center mb-6">
<NoResultsPlaceholder
:message="$t('manager.status.unknown')"
:title="$t('manager.tryAgainLater')"
@@ -45,9 +40,8 @@ import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { components } from '@/types/comfyRegistryTypes'
const { nodePacks, hasConflict } = defineProps<{
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
hasConflict?: boolean
}>()
const managerStore = useComfyManagerStore()

View File

@@ -6,12 +6,8 @@
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
<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>
{{ nodePacks.length }}
{{ $t('manager.packsSelected') }}
</template>
<template #install-button>
<PackInstallButton :full-width="true" :node-packs="nodePacks" />

View File

@@ -1,31 +1,15 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<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">
<TabList>
<Tab value="description">
{{ $t('g.description') }}
</Tab>
<Tab value="nodes" class="p-2">
<Tab value="nodes">
{{ $t('g.nodes') }}
</Tab>
</TabList>
<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>
<TabPanels class="overflow-auto">
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
@@ -43,18 +27,14 @@ import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, ref, watchEffect } from 'vue'
import { computed, ref } 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, hasCompatibilityIssues, conflictResult } = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
hasCompatibilityIssues?: boolean
conflictResult?: ConflictDetectionResult | null
}>()
const nodeNames = computed(() => {
@@ -64,17 +44,4 @@ 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-3">
<div class="mb-1">
{{ 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="overflow-hidden">
<div class="mt-4 overflow-hidden">
<InfoTextSection
v-if="nodePack?.description"
:sections="descriptionSections"

View File

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

View File

@@ -1,24 +0,0 @@
<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 max-h-12 line-clamp-3 my-0 leading-4 mb-2 overflow-hidden"
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"
>
{{ nodePack.description }}
</p>
@@ -119,21 +119,13 @@ provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
const isDisabled = ref(false)
const managerStore = useComfyManagerStore()
// 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 isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !isPackEnabled(nodePack?.id)
)
whenever(isInstalled, () => (isInstalling.value = false))
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
)

View File

@@ -1,22 +1,13 @@
<template>
<div
class="h-12 flex justify-between items-center px-4 text-xs text-muted font-medium leading-3"
class="min-h-12 flex justify-between items-center px-4 py-2 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>
<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>
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
<PackEnableToggle v-else :node-pack="nodePack" />
</div>
</template>
@@ -26,9 +17,7 @@ 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<{
@@ -43,24 +32,4 @@ 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,10 +20,7 @@
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isInstalled">
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="!!packageConflicts"
/>
<PackEnableToggle :node-pack="nodePack" />
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
@@ -38,7 +35,6 @@ 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<{
@@ -46,13 +42,5 @@ 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,37 +1,11 @@
<template>
<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>
<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"
/>
</template>
<script setup lang="ts">
@@ -39,14 +13,29 @@ import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
const { nodePack } = defineProps<{
const {
nodePack,
width = '4.5rem',
height = '4.5rem'
} = 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 showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -1,19 +1,25 @@
<template>
<div class="relative w-[224px] h-[104px] shadow-xl">
<div class="relative w-24 h-24">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute w-[210px] h-[90px]"
class="absolute"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div class="border rounded-lg shadow-lg p-0.5">
<PackIcon :node-pack="pack" />
<div class="border rounded-lg p-0.5">
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
</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,10 +33,6 @@
: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">
@@ -69,10 +65,8 @@ 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
@@ -89,7 +83,6 @@ const { searchResults, sortOptions } = defineProps<{
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery')
@@ -103,10 +96,6 @@ 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,44 +1,41 @@
<template>
<div
class="w-full px-6 py-2 shadow-lg flex items-center justify-between"
class="w-full px-6 py-4 shadow-lg flex items-center justify-between"
:class="{
'rounded-t-none': progressDialogContent.isExpanded,
'rounded-lg': !progressDialogContent.isExpanded
}"
>
<div class="flex items-center text-base leading-none">
<div class="justify-center text-sm font-bold leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<DotSpinner duration="1s" class="mr-2" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else-if="isRestartCompleted">
<span class="mr-2">🎉</span>
<i class="pi pi-spin pi-spinner mr-2 text-3xl" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<span class="mr-2"></span>
<span>{{ $t('manager.restartToApplyChanges') }}</span>
<i class="pi pi-check-circle mr-2 text-green-500" />
<span class="leading-none">{{
$t('manager.restartToApplyChanges')
}}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-sm text-neutral-700">
{{ completedTasksCount }} {{ $t('g.progressCountOf') }}
<span v-if="isInProgress" class="text-xs font-bold text-neutral-600">
{{ comfyManagerStore.uncompletedCount }} {{ $t('g.progressCountOf') }}
{{ comfyManagerStore.taskLogs.length }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress && !isRestartCompleted"
v-if="!isInProgress"
rounded
outlined
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"
class="px-4 py-2 rounded-md mr-4"
@click="handleRestart"
>
{{ $t('manager.applyChanges') }}
{{ $t('g.restart') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
:icon="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
@@ -47,7 +44,6 @@
text
rounded
size="small"
class="font-bold"
severity="secondary"
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
@click.stop="progressDialogContent.toggle"
@@ -57,7 +53,6 @@
text
rounded
size="small"
class="font-bold"
severity="secondary"
aria-label="Close"
@click.stop="closeDialog"
@@ -70,10 +65,9 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { computed } 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'
@@ -83,93 +77,41 @@ 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()
// 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 isInProgress = computed(() => comfyManagerStore.uncompletedCount > 0)
const closeDialog = () => {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
}
const fallbackTaskName = t('manager.installingDependencies')
const fallbackTaskName = t('g.installing')
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 () => {
// Store original toast setting value
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
const onReconnect = async () => {
// Refresh manager state
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
comfyManagerStore.clearLogs()
comfyManagerStore.setStale()
isRestarting.value = true
// Refresh node definitions
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
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
// Reload workflow
await useWorkflowService().reloadCurrentWorkflow()
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
closeDialog()
}
</script>

View File

@@ -18,40 +18,16 @@
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { computed } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
import { useManagerProgressDialogStore } from '@/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const activeTabIndex = computed({
get: () => progressDialogContent.getActiveTabIndex(),
set: (value: number) => progressDialogContent.setActiveTabIndex(value)
})
const activeTabIndex = ref(0)
const { t } = useI18n()
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 }
])
const tabs = [
{ label: t('manager.installationQueue') },
{ label: t('manager.failed', { count: 0 }) }
]
</script>

View File

@@ -41,15 +41,7 @@ const previousCanvasDraggable = ref(true)
const onEdit = (newValue: string) => {
if (titleEditorStore.titleEditorTarget && 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
}
titleEditorStore.titleEditorTarget.title = newValue.trim()
app.graph.setDirtyCanvas(true, true)
}
showInput.value = false

View File

@@ -14,17 +14,7 @@
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<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>
<i :class="menuItem.icon" class="help-menu-icon" />
<span class="menu-label">{{ menuItem.label }}</span>
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
</button>
@@ -64,7 +54,7 @@
</Teleport>
<!-- What's New Section -->
<section v-if="showVersionUpdates" class="whats-new-section">
<section class="whats-new-section">
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
<!-- Release Items -->
@@ -129,38 +119,25 @@
</template>
<script setup lang="ts">
// No need to import useStorage anymore - it's in the composable
import Button from 'primevue/button'
import {
type CSSProperties,
type Component,
computed,
nextTick,
onMounted,
ref
} from 'vue'
import { type CSSProperties, 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 | Component
icon?: string
label?: string
action?: () => void
visible?: boolean
type?: 'item' | 'divider'
items?: MenuItem[]
showRedDot?: boolean
}
// Constants
@@ -184,15 +161,13 @@ const TIME_UNITS = {
const SUBMENU_CONFIG = {
DELAY_MS: 100,
OFFSET_PX: 8,
Z_INDEX: 10001
Z_INDEX: 1002
} as const
// Composables
const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogService = useDialogService()
// Emits
const emit = defineEmits<{
@@ -207,19 +182,11 @@ 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[] = [
{
@@ -299,17 +266,6 @@ 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',
@@ -544,39 +500,16 @@ onMounted(async () => {
box-shadow: none;
}
.help-menu-icon-container {
position: relative;
margin-right: 0.75rem;
width: 16px;
flex-shrink: 0;
}
.help-menu-icon {
margin-right: 0.75rem;
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,32 +32,28 @@
<div class="whats-new-popup" @click.stop>
<!-- Close Button -->
<button
class="close-button"
:aria-label="$t('g.close')"
@click="closePopup"
>
<button class="close-button" aria-label="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> -->
</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> -->
</div>
</div>
</div>
@@ -72,14 +68,9 @@ import type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
const { locale, t } = useI18n()
const { locale } = useI18n()
const releaseStore = useReleaseStore()
// Emit event for parent component
const emit = defineEmits<{
'whats-new-dismissed': []
}>()
// Local state for dismissed status
const isDismissed = ref(false)
@@ -110,12 +101,13 @@ const changelogUrl = computed(() => {
// Format release content for display using marked
const formattedContent = computed(() => {
if (!latestRelease.value?.content) {
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
return '<p>No release notes available.</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) {
@@ -139,10 +131,6 @@ 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
@@ -211,10 +199,14 @@ 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);
@@ -225,11 +217,6 @@ 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 */
@@ -237,17 +224,17 @@ defineExpose({
position: absolute;
top: 0;
right: 0;
width: 32px;
height: 32px;
padding: 6px;
width: 31px;
height: 31px;
padding: 6px 7px;
background: #7c7c7c;
border-radius: 16px;
border-radius: 15.5px;
border: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
transform: translate(30%, -30%);
transform: translate(50%, -50%);
transition:
background-color 0.2s ease,
transform 0.1s ease;
@@ -260,7 +247,7 @@ defineExpose({
.close-button:active {
background: #6a6a6a;
transform: translate(30%, -30%) scale(0.95);
transform: translate(50%, -50%) scale(0.95);
}
.close-icon {
@@ -301,45 +288,73 @@ 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) {
font-size: 16px;
color: white;
font-size: 20px;
font-weight: 700;
margin-bottom: 8px;
margin: 0 0 16px 0;
line-height: 1.3;
}
/* Version subtitle - targets the first p tag after h1 */
.content-text :deep(h1 + p) {
color: #c0c0c0;
.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: 500;
margin-bottom: 16px;
opacity: 0.8;
font-weight: 600;
margin: 12px 0 8px 0;
line-height: 1.3;
}
.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;
}
/* Regular paragraphs - short description */
.content-text :deep(p) {
margin-bottom: 16px;
color: #e0e0e0;
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;
}
/* List */
.content-text :deep(ul),
.content-text :deep(ol) {
margin-bottom: 16px;
padding-left: 0;
list-style: none;
margin: 0 0 12px 0;
padding-left: 24px;
}
.content-text :deep(ul:first-child),
@@ -352,63 +367,12 @@ 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: 24px;
margin-bottom: 12px;
}
/* Media elements */
@@ -417,7 +381,8 @@ defineExpose({
.content-text :deep(iframe) {
width: calc(100% + 64px);
height: auto;
margin: 24px -32px;
border-radius: 6px;
margin: 12px -32px;
display: block;
}
@@ -432,6 +397,7 @@ defineExpose({
.learn-more-link {
color: #60a5fa;
font-size: 14px;
font-family: 'Inter', sans-serif;
font-weight: 500;
line-height: 18.2px;
text-decoration: none;
@@ -451,6 +417,7 @@ defineExpose({
border: none;
color: #121212;
font-size: 14px;
font-family: 'Inter', sans-serif;
font-weight: 500;
cursor: pointer;
}

View File

@@ -1,43 +0,0 @@
<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

@@ -1,29 +0,0 @@
<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,6 +55,7 @@
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@upload-texture="handleUploadTexture"
@export-model="handleExportModel"
/>
<div
@@ -214,6 +215,30 @@ 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,6 +51,7 @@
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@upload-texture="handleUploadTexture"
/>
<CameraControls
@@ -182,6 +183,7 @@ 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)
@@ -230,6 +232,10 @@ const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUploadTexture = (file: File) => {
emit('uploadTexture', file)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}

View File

@@ -69,6 +69,9 @@ 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,6 +59,32 @@
</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"
@@ -116,6 +142,7 @@ 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')
@@ -124,6 +151,7 @@ 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',
@@ -219,6 +247,18 @@ 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

@@ -6,8 +6,7 @@
:key="tab.id"
:icon="tab.icon"
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:tooltip="tab.tooltip + getTabTooltipSuffix(tab)"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"

View File

@@ -42,12 +42,11 @@
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="body">
<Teleport to="#graph-canvas-container">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@@ -58,14 +57,12 @@
</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'
@@ -73,20 +70,9 @@ import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const conflictDetection = useConflictDetection()
const conflictBannerState = useConflictBannerState()
const dialogService = useDialogService()
const { shouldShowRedDot } = storeToRefs(releaseStore)
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')
)
@@ -101,44 +87,6 @@ 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
@@ -153,14 +101,14 @@ onMounted(async () => {
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
z-index: 999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 10000;
z-index: 1000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}

View File

@@ -4,7 +4,6 @@ import PrimeVue from 'primevue/config'
import OverlayBadge from 'primevue/overlaybadge'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import SidebarIcon from './SidebarIcon.vue'
@@ -16,14 +15,6 @@ type SidebarIconProps = {
iconBadge?: string | (() => string | null)
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {}
}
})
describe('SidebarIcon', () => {
const exampleProps: SidebarIconProps = {
icon: 'pi pi-cog',
@@ -33,7 +24,7 @@ describe('SidebarIcon', () => {
const mountSidebarIcon = (props: Partial<SidebarIconProps>, options = {}) => {
return mount(SidebarIcon, {
global: {
plugins: [PrimeVue, i18n],
plugins: [PrimeVue],
directives: { tooltip: Tooltip },
components: { OverlayBadge, Button }
},

View File

@@ -1,10 +1,6 @@
<template>
<Button
v-tooltip="{
value: computedTooltip,
showDelay: 300,
hideDelay: 300
}"
v-tooltip="{ value: tooltip, showDelay: 300, hideDelay: 300 }"
text
:pt="{
root: {
@@ -13,7 +9,7 @@
? 'p-button-primary side-bar-button-selected'
: 'p-button-secondary'
}`,
'aria-label': computedTooltip
'aria-label': tooltip
}
}"
@click="emit('click', $event)"
@@ -31,20 +27,16 @@
import Button from 'primevue/button'
import OverlayBadge from 'primevue/overlaybadge'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const {
icon = '',
selected = false,
tooltip = '',
tooltipSuffix = '',
iconBadge = ''
} = defineProps<{
icon?: string
selected?: boolean
tooltip?: string
tooltipSuffix?: string
iconBadge?: string | (() => string | null)
}>()
@@ -55,7 +47,6 @@ const overlayValue = computed(() =>
typeof iconBadge === 'function' ? iconBadge() ?? '' : iconBadge
)
const shouldShowBadge = computed(() => !!overlayValue.value)
const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
</script>
<style>

View File

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

View File

@@ -111,55 +111,30 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
displayPrice: '$0.06/Run'
},
IdeogramV1: {
displayPrice: (node: LGraphNode): string => {
const numImagesWidget = node.widgets?.find(
(w) => w.name === 'num_images'
) as IComboWidget
if (!numImagesWidget) return '$0.06 x num_images/Run'
const numImages = Number(numImagesWidget.value) || 1
const cost = (0.06 * numImages).toFixed(2)
return `$${cost}/Run`
}
displayPrice: '$0.06/Run'
},
IdeogramV2: {
displayPrice: (node: LGraphNode): string => {
const numImagesWidget = node.widgets?.find(
(w) => w.name === 'num_images'
) as IComboWidget
if (!numImagesWidget) return '$0.08 x num_images/Run'
const numImages = Number(numImagesWidget.value) || 1
const cost = (0.08 * numImages).toFixed(2)
return `$${cost}/Run`
}
displayPrice: '$0.08/Run'
},
IdeogramV3: {
displayPrice: (node: LGraphNode): string => {
const renderingSpeedWidget = node.widgets?.find(
(w) => w.name === 'rendering_speed'
) as IComboWidget
const numImagesWidget = node.widgets?.find(
(w) => w.name === 'num_images'
) as IComboWidget
if (!renderingSpeedWidget)
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
const numImages = Number(numImagesWidget?.value) || 1
let basePrice = 0.06 // default balanced price
return '$0.03-0.08/Run (varies with rendering speed)'
const renderingSpeed = String(renderingSpeedWidget.value)
if (renderingSpeed.toLowerCase().includes('quality')) {
basePrice = 0.09
return '$0.08/Run'
} else if (renderingSpeed.toLowerCase().includes('balanced')) {
basePrice = 0.06
return '$0.06/Run'
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
basePrice = 0.03
return '$0.03/Run'
}
const totalCost = (basePrice * numImages).toFixed(2)
return `$${totalCost}/Run`
return '$0.06/Run'
}
},
KlingCameraControlI2VNode: {
@@ -275,33 +250,30 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modelWidget = node.widgets?.find(
(w) => w.name === 'model_name'
) as IComboWidget
const nWidget = node.widgets?.find(
(w) => w.name === 'n'
) as IComboWidget
if (!modelWidget)
return '$0.0035-0.028 x n/Run (varies with modality & model)'
return '$0.0035-0.028/Run (varies with modality & model)'
const model = String(modelWidget.value)
const n = Number(nWidget?.value) || 1
let basePrice = 0.014 // default
if (modality.includes('text to image')) {
if (model.includes('kling-v1-5') || model.includes('kling-v2')) {
basePrice = 0.014
} else if (model.includes('kling-v1')) {
basePrice = 0.0035
if (model.includes('kling-v1')) {
return '$0.0035/Run'
} else if (
model.includes('kling-v1-5') ||
model.includes('kling-v2')
) {
return '$0.014/Run'
}
} else if (modality.includes('image to image')) {
if (model.includes('kling-v1-5')) {
basePrice = 0.028
} else if (model.includes('kling-v1')) {
basePrice = 0.0035
if (model.includes('kling-v1')) {
return '$0.0035/Run'
} else if (model.includes('kling-v1-5')) {
return '$0.028/Run'
}
}
const totalCost = (basePrice * n).toFixed(4)
return `$${totalCost}/Run`
return '$0.014/Run'
}
},
KlingLipSyncAudioToVideoNode: {
@@ -322,15 +294,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const effectScene = String(effectSceneWidget.value)
if (
effectScene.includes('fuzzyfuzzy') ||
effectScene.includes('squish')
effectScene.includes('squish') ||
effectScene.includes('expansion')
) {
return '$0.28/Run'
} else if (effectScene.includes('dizzydizzy')) {
} else if (
effectScene.includes('dizzydizzy') ||
effectScene.includes('bloombloom')
) {
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 +420,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 '$1.59/Run'
if (resolution.includes('1080p')) return '$2.30/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 '$2.87/Run'
if (resolution.includes('1080p')) return '$4.14/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
}
@@ -499,12 +471,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 '$1.59/Run'
if (resolution.includes('1080p')) return '$2.30/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 '$2.87/Run'
if (resolution.includes('1080p')) return '$4.14/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
}
@@ -526,26 +498,19 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const sizeWidget = node.widgets?.find(
(w) => w.name === 'size'
) as IComboWidget
const nWidget = node.widgets?.find(
(w) => w.name === 'n'
) as IComboWidget
if (!sizeWidget) return '$0.016-0.02 x n/Run (varies with size & n)'
if (!sizeWidget) return '$0.016-0.02/Run (varies with size)'
const size = String(sizeWidget.value)
const n = Number(nWidget?.value) || 1
let basePrice = 0.02 // default
if (size.includes('1024x1024')) {
basePrice = 0.02
return '$0.02/Run'
} else if (size.includes('512x512')) {
basePrice = 0.018
return '$0.018/Run'
} else if (size.includes('256x256')) {
basePrice = 0.016
return '$0.016/Run'
}
const totalCost = (basePrice * n).toFixed(3)
return `$${totalCost}/Run`
return '$0.02/Run'
}
},
OpenAIDalle3: {
@@ -580,30 +545,19 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const qualityWidget = node.widgets?.find(
(w) => w.name === 'quality'
) as IComboWidget
const nWidget = node.widgets?.find(
(w) => w.name === 'n'
) as IComboWidget
if (!qualityWidget)
return '$0.011-0.30 x n/Run (varies with quality & n)'
if (!qualityWidget) return '$0.011-0.30/Run (varies with quality)'
const quality = String(qualityWidget.value)
const n = Number(nWidget?.value) || 1
let basePriceRange = '$0.046-0.07' // default medium
if (quality.includes('high')) {
basePriceRange = '$0.167-0.30'
return '$0.167-0.30/Run'
} else if (quality.includes('medium')) {
basePriceRange = '$0.046-0.07'
return '$0.046-0.07/Run'
} else if (quality.includes('low')) {
basePriceRange = '$0.011-0.02'
return '$0.011-0.02/Run'
}
if (n === 1) {
return `${basePriceRange}/Run`
} else {
return `${basePriceRange} x ${n}/Run`
}
return '$0.046-0.07/Run'
}
},
PikaImageToVideoNode2_2: {
@@ -738,42 +692,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
RecraftCrispUpscaleNode: {
displayPrice: '$0.004/Run'
},
RecraftGenerateColorFromImageNode: {
displayPrice: (node: LGraphNode): string => {
const nWidget = node.widgets?.find(
(w) => w.name === 'n'
) as IComboWidget
if (!nWidget) return '$0.04 x n/Run'
const n = Number(nWidget.value) || 1
const cost = (0.04 * n).toFixed(2)
return `$${cost}/Run`
}
},
RecraftGenerateImageNode: {
displayPrice: (node: LGraphNode): string => {
const nWidget = node.widgets?.find(
(w) => w.name === 'n'
) as IComboWidget
if (!nWidget) return '$0.04 x n/Run'
const n = Number(nWidget.value) || 1
const cost = (0.04 * n).toFixed(2)
return `$${cost}/Run`
}
},
RecraftGenerateVectorImageNode: {
displayPrice: (node: LGraphNode): string => {
const nWidget = node.widgets?.find(
(w) => w.name === 'n'
) as IComboWidget
if (!nWidget) return '$0.08 x n/Run'
const n = Number(nWidget.value) || 1
const cost = (0.08 * n).toFixed(2)
return `$${cost}/Run`
}
},
RecraftImageInpaintingNode: {
displayPrice: (node: LGraphNode): string => {
const nWidget = node.widgets?.find(
@@ -829,16 +747,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
}
},
RecraftVectorizeImageNode: {
displayPrice: (node: LGraphNode): string => {
const nWidget = node.widgets?.find(
(w) => w.name === 'n'
) as IComboWidget
if (!nWidget) return '$0.01 x n/Run'
const n = Number(nWidget.value) || 1
const cost = (0.01 * n).toFixed(2)
return `$${cost}/Run`
}
displayPrice: '$0.01/Run'
},
StabilityStableImageSD_3_5Node: {
displayPrice: (node: LGraphNode): string => {
@@ -947,63 +856,6 @@ 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'
}
}
}
@@ -1038,16 +890,14 @@ export const useNodePricing = () => {
const widgetMap: Record<string, string[]> = {
KlingTextToVideoNode: ['mode', 'model_name', 'duration'],
KlingImage2VideoNode: ['mode', 'model_name', 'duration'],
KlingImageGenerationNode: ['modality', 'model_name', 'n'],
KlingImageGenerationNode: ['modality', 'model_name'],
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],
OpenAIGPTImage1: ['quality', 'n'],
IdeogramV1: ['num_images'],
IdeogramV2: ['num_images'],
IdeogramV3: ['rendering_speed', 'num_images'],
OpenAIDalle2: ['size'],
OpenAIGPTImage1: ['quality'],
IdeogramV3: ['rendering_speed'],
VeoVideoGenerationNode: ['duration_seconds'],
LumaVideoNode: ['model', 'resolution', 'duration'],
LumaImageToVideoNode: ['model', 'resolution', 'duration'],
@@ -1068,14 +918,7 @@ export const useNodePricing = () => {
RecraftTextToImageNode: ['n'],
RecraftImageToImageNode: ['n'],
RecraftImageInpaintingNode: ['n'],
RecraftTextToVectorNode: ['n'],
RecraftVectorizeImageNode: ['n'],
RecraftGenerateColorFromImageNode: ['n'],
RecraftGenerateImageNode: ['n'],
RecraftGenerateVectorImageNode: ['n'],
MoonvalleyTxt2VideoNode: ['length'],
MoonvalleyImg2VideoNode: ['length'],
MoonvalleyVideo2VideoNode: ['length']
RecraftTextToVectorNode: ['n']
}
return widgetMap[nodeType] || []
}

View File

@@ -20,10 +20,7 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
const startFetchInstalled = async () => {
// Only refresh if store doesn't have data yet
if (comfyManagerStore.installedPacksIds.size === 0) {
await comfyManagerStore.refreshInstalledList()
}
await comfyManagerStore.refreshInstalledList()
await startFetch()
}
@@ -36,29 +33,11 @@ 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

@@ -1,65 +0,0 @@
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 { UseNodePacksOptions } from '@/types/comfyManagerTypes'
import { SelectedVersion, UseNodePacksOptions } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
type WorkflowPack = {
@@ -26,7 +26,7 @@ const CORE_NODES_PACK_NAME = 'comfy-core'
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const nodeDefStore = useNodeDefStore()
const systemStatsStore = useSystemStatsStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const { search } = useComfyRegistryStore()
const workflowPacks = ref<WorkflowPack[]>([])
@@ -65,23 +65,23 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
return {
id: CORE_NODES_PACK_NAME,
version:
systemStatsStore.systemStats?.system?.comfyui_version ?? 'nightly'
systemStatsStore.systemStats?.system?.comfyui_version ??
SelectedVersion.NIGHTLY
}
}
// Query the registry to find which pack provides this node
const pack = await inferPackFromNodeName.call(nodeName)
if (pack) {
// Search the registry for non-core nodes
const searchResult = await search.call({
comfy_node_search: nodeName,
limit: 1
})
if (searchResult?.nodes?.length) {
const pack = searchResult.nodes[0]
return {
id: pack.id,
version: pack.latest_version?.version ?? 'nightly'
version: pack.latest_version?.version ?? SelectedVersion.NIGHTLY
}
}
// No pack found - this node doesn't exist in the registry or couldn't be
// extracted from the parent node pack successfully
return undefined
}
/**

View File

@@ -53,11 +53,6 @@ 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

@@ -1,4 +1,5 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
@@ -6,11 +7,13 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
import { isElectron } from '@/utils/envUtil'
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
return {
id: 'model-library',
icon: 'pi pi-box',
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
title: t('sideToolbar.modelLibrary'),
tooltip: t('sideToolbar.modelLibrary'),
component: markRaw(ModelLibrarySidebarTab),
type: 'vue',
iconBadge: () => {

View File

@@ -1,14 +1,16 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
return {
id: 'node-library',
icon: 'pi pi-book',
title: 'sideToolbar.nodeLibrary',
tooltip: 'sideToolbar.nodeLibrary',
title: t('sideToolbar.nodeLibrary'),
tooltip: t('sideToolbar.nodeLibrary'),
component: markRaw(NodeLibrarySidebarTab),
type: 'vue'
}

View File

@@ -1,10 +1,12 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueSidebarTab from '@/components/sidebar/tabs/QueueSidebarTab.vue'
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useQueueSidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
return {
id: 'queue',
@@ -13,8 +15,8 @@ export const useQueueSidebarTab = (): SidebarTabExtension => {
const value = queuePendingTaskCountStore.count.toString()
return value === '0' ? null : value
},
title: 'sideToolbar.queue',
tooltip: 'sideToolbar.queue',
title: t('sideToolbar.queue'),
tooltip: t('sideToolbar.queue'),
component: markRaw(QueueSidebarTab),
type: 'vue'
}

View File

@@ -1,4 +1,5 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import { useSettingStore } from '@/stores/settingStore'
@@ -6,8 +7,10 @@ import { useWorkflowStore } from '@/stores/workflowStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
return {
id: 'workflows',
icon: 'pi pi-folder-open',
@@ -20,8 +23,8 @@ export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
const value = workflowStore.openWorkflows.length.toString()
return value === '0' ? null : value
},
title: 'sideToolbar.workflows',
tooltip: 'sideToolbar.workflows',
title: t('sideToolbar.workflows'),
tooltip: t('sideToolbar.workflows'),
component: markRaw(WorkflowsSidebarTab),
type: 'vue'
}

View File

@@ -1,223 +0,0 @@
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

@@ -1,71 +0,0 @@
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,12 +15,10 @@ 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, useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { ComfyCommand } from '@/stores/commandStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -30,7 +28,6 @@ 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'
@@ -42,7 +39,6 @@ export function useCoreCommands(): ComfyCommand[] {
const firebaseAuthActions = useFirebaseAuthActions()
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
const getSelectedNodes = (): LGraphNode[] => {
@@ -207,7 +203,7 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-stop',
label: 'Interrupt',
function: async () => {
await api.interrupt(executionStore.activePromptId)
await api.interrupt()
toastStore.add({
severity: 'info',
summary: t('g.interrupted'),
@@ -662,53 +658,12 @@ export function useCoreCommands(): ComfyCommand[] {
}
},
{
id: 'Comfy.Manager.CustomNodesManager.ShowCustomNodesMenu',
id: 'Comfy.Manager.CustomNodesManager',
icon: 'pi pi-puzzle',
label: 'Show the Custom Nodes Manager',
label: 'Toggle 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.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
})
dialogService.toggleManagerDialog()
}
},
{
@@ -797,88 +752,9 @@ 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,153 +1,101 @@
import { useEventListener, whenever } from '@vueuse/core'
import { mapKeys, pickBy } from 'lodash'
import { Ref, computed, ref } from 'vue'
import { computed, readonly, ref } from 'vue'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { components } from '@/types/generatedManagerTypes'
import { api } from '@/scripts/api'
import { ManagerWsQueueStatus } from '@/types/comfyManagerTypes'
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
type QueuedTask<T> = {
task: () => Promise<T>
onComplete?: () => void
}
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'
const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
const MANAGER_WS_MSG_TYPE = 'cm-queue-status'
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
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
)
/**
* 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 serverQueueStatus = ref<ManagerWsQueueStatus>(ManagerWsQueueStatus.DONE)
const isServerIdle = computed(
() => serverQueueStatus.value === ManagerWsQueueStatus.DONE
)
const allTasksDone = computed(() => currentQueueLength.value === 0)
const historyCount = computed(() => Object.keys(taskHistory.value).length)
const allTasksDone = computed(
() => isServerIdle.value && clientQueueLength.value === 0
)
const nextTaskReady = computed(
() => isServerIdle.value && clientQueueLength.value > 0
)
/**
* 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 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
}
}
)
// 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)
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--
}
}
)
whenever(currentQueueLength, () => showManagerProgressDialog())
const stopListening = () => {
cleanupTaskDoneListener()
cleanupTaskStartedListener()
}
})
return {
// Queue state (read-only from server)
taskHistory,
taskQueue,
maxHistoryItems,
isLoading,
// Computed state
allTasksDone,
isProcessing,
queueLength: currentQueueLength,
historyCount,
statusMessage: readonly(serverQueueStatus),
queueLength: clientQueueLength,
uncompletedCount,
// Actions
stopListening
enqueueTask,
clearQueue,
cleanup
}
}

View File

@@ -1,34 +1,24 @@
import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import { onUnmounted, 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[]>([])
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
let stop: ReturnType<typeof useEventListener> | null = null
const isValidLogEvent = (event: CustomEvent<LogsWsMessage>) =>
event?.type === LOGS_MESSAGE_TYPE && event.detail?.entries?.length > 0
@@ -37,81 +27,34 @@ 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)) {
const messages = parseLogMessage(event)
if (messages.length > 0) {
logs.value.push(...messages)
}
logs.value.push(...parseLogMessage(event))
}
}
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 () => {
const start = async () => {
await api.subscribeLogs(true)
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
)
stop = useEventListener(api, LOGS_MESSAGE_TYPE, handleLogMessage)
}
const stopListening = async () => {
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)
stop?.()
stop = null
await api.subscribeLogs(false)
}
if (immediate) {
void startListening()
void start()
}
const cleanup = async () => {
onUnmounted(async () => {
await stopListening()
logs.value = []
isTaskStarted.value = false
}
})
return {
logs,
startListening,
stopListening,
cleanup
startListening: start,
stopListening
}
}

View File

@@ -10,19 +10,14 @@ import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
function onFloatValueChange(this: INumericWidget, v: number) {
const round = this.options.round
if (round) {
const precision =
this.options.precision ?? Math.max(0, -Math.floor(Math.log10(round)))
const rounded = Math.round(v / round) * round
this.value = _.clamp(
Number(rounded.toFixed(precision)),
this.options.min ?? -Infinity,
this.options.max ?? Infinity
)
} else {
this.value = v
}
this.value = this.options.round
? _.clamp(
Math.round((v + Number.EPSILON) / this.options.round) *
this.options.round,
this.options.min ?? -Infinity,
this.options.max ?? Infinity
)
: v
}
export const _for_testing = {
@@ -67,7 +62,7 @@ export const useFloatWidget = () => {
max: inputSpec.max ?? 2048,
round:
enableRounding && precision && !inputSpec.round
? Math.pow(10, -precision)
? (1_000_000 * Math.pow(0.1, precision)) / 1_000_000
: (inputSpec.round as number),
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10.0,

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