Merge remote-tracking branch 'upstream/main' into js/async_nodes

This commit is contained in:
Jacob Segal
2025-07-10 15:35:08 -07:00
75 changed files with 15370 additions and 519 deletions

View File

@@ -0,0 +1,654 @@
# 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 analyze recent changes and recommend the appropriate version type.
</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 promoting to 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: Semantic Version Determination
Based on analysis, determine version type:
**Pre-release Handling:**
- If current version is pre-release (e.g., 1.24.0-1):
- Consider promoting to stable (1.24.0) instead of new version
- Or create new minor/major if significant changes added
**Automatic Detection:**
- **MAJOR**: Breaking changes detected (`BREAKING CHANGE`, `!` in commits)
- **MINOR**: New features without breaking changes (`feat:` commits)
- **PATCH**: Only bug fixes, docs, or dependency updates
**Version Workflow Limitations:**
- ⚠️ Cannot use "stable" as version_type - not in allowed values
- Allowed values: patch, minor, major, prepatch, preminor, premajor, prerelease
- For pre-release → stable promotion, must manually update version
**Manual Override Options:**
- If arguments provided, validate against detected changes
- **CONFIRMATION REQUIRED**: Version type correct for these changes?
- **WARNING**: If manual override conflicts with detected breaking changes
**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
For minor/major releases:
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 pre-release → stable promotion:**
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
## Common Issues and Solutions
### Issue: Pre-release Version Confusion
**Problem**: Not sure whether to promote pre-release or create new version
**Solution**:
- If no new commits since pre-release: promote to stable
- If new commits exist: consider new minor version
### Issue: Wrong Commit Count
**Problem**: Changelog includes commits from other branches
**Solution**: Always use `--first-parent` flag with git log
### Issue: Release Workflow Doesn't Trigger
**Problem**: update-locales adds [skip ci] to PR
**Solution**:
1. Create patch release to trigger workflow
2. Alternative: Revert version and re-run version bump workflow
3. Fix update-locales to skip [skip ci] for Release PRs
**Update**: Sometimes update-locales doesn't add [skip ci] - always verify!
### Issue: Version Workflow Limitations
**Problem**: Cannot use "stable" as version_type
**Solution**: Manually create PR for pre-release → stable promotion
### 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
**Recovery Strategy**:
1. Revert version in a new PR (e.g., 1.24.0 → 1.24.0-1)
2. Merge the revert PR
3. Run version bump workflow again
4. This creates a fresh PR without [skip ci]
Benefits: Cleaner than creating extra version numbers
## Key Learnings & Notes
1. **PR Author**: Version bump PRs are created by `comfy-pr-bot`, not `github-actions`
2. **Workflow Speed**: Version bump workflow typically completes in ~20-30 seconds
3. **Update-locales Behavior**: Inconsistent - sometimes adds [skip ci], sometimes doesn't
4. **Recovery Options**: Reverting version is cleaner than creating extra versions

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

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

View File

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

View File

@@ -1,59 +0,0 @@
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,9 +1,24 @@
import type { OutputOptions } from 'rollup'
import { HtmlTagDescriptor, Plugin } from 'vite'
import glob from 'fast-glob'
import fs from 'fs-extra'
import { dirname, join } from 'node:path'
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
interface VendorLibrary {
interface ImportMapSource {
name: string
pattern: RegExp
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 []
}
/**
@@ -23,53 +38,89 @@ interface VendorLibrary {
* @returns {Plugin} A Vite plugin that generates and injects an import map
*/
export function generateImportMapPlugin(
vendorLibraries: VendorLibrary[]
importMapSources: ImportMapSource[]
): 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 = {}
}
const outputOptions: OutputOptions = {
manualChunks: (id: string) => {
for (const lib of vendorLibraries) {
if (lib.pattern.test(id)) {
return `vendor-${lib.name}`
}
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)
}
return null
},
// Disable minification of internal exports to preserve function names
minifyInternalExports: false
}
}
config.build.rollupOptions.output = outputOptions
const external: (string | RegExp)[] = []
for (const [, source] of resolvedImportMapSources) {
external.push(source.pattern)
}
config.build.rollupOptions.external = external
}
},
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}`
)
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)
if (vendorLib) {
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
importMapEntries[vendorLib.name] = relativePath
importMapEntries[source.name] =
'./' + normalizePath(join(assetDir, moduleFile))
console.log(
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${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)
}
}
}

View File

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

12
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.0-0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.0-0",
"version": "1.24.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.3",
"@comfyorg/litegraph": "^0.16.6",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -949,9 +949,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.3.tgz",
"integrity": "sha512-dst29g8+aZW8sWTYxj3LK1W4lX07elBPWFB1L4HLTkYgkzQoyBkHR1O2lSvAn+7bKagi0Q5PjIcZnWG+JAi0lg==",
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.6.tgz",
"integrity": "sha512-pRmJYZ39rIpGIaJAaOLicRFe3KyeNTXNAAB0+Thz8cPGpu2dBv8W6PlOu94VYNRc+pBhEwV+jJVlXb5YyAvBXQ==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.24.0-0",
"version": "1.24.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -77,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.3",
"@comfyorg/litegraph": "^0.16.6",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

192
scripts/check-unused-i18n-keys.ts Executable file
View File

@@ -0,0 +1,192 @@
#!/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

@@ -77,6 +77,7 @@ import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { newUserService } from '@/services/newUserService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -305,6 +306,9 @@ onMounted(async () => {
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})
await newUserService().initializeIfNewUser(settingStore)
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas

View File

@@ -4,6 +4,7 @@
<nav class="help-menu-section" role="menubar">
<button
v-for="menuItem in menuItems"
v-show="menuItem.visible !== false"
:key="menuItem.key"
type="button"
class="help-menu-item"
@@ -29,14 +30,20 @@
@mouseenter="onSubmenuHover"
@mouseleave="onSubmenuLeave"
>
<template v-for="submenuItem in submenuItems" :key="submenuItem.key">
<div v-if="submenuItem.type === 'divider'" class="submenu-divider" />
<template
v-for="submenuItem in moreMenuItem?.items"
:key="submenuItem.key"
>
<div
v-if="submenuItem.type === 'divider'"
v-show="submenuItem.visible !== false"
class="submenu-divider"
/>
<button
v-else
v-show="submenuItem.visible !== false"
type="button"
class="help-menu-item submenu-item"
:class="{ disabled: submenuItem.disabled }"
:disabled="submenuItem.disabled"
role="menuitem"
@click="submenuItem.action"
>
@@ -47,7 +54,7 @@
</Teleport>
<!-- What's New Section -->
<section class="whats-new-section">
<section v-if="showVersionUpdates" class="whats-new-section">
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
<!-- Release Items -->
@@ -117,24 +124,21 @@ import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
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
label: string
action: () => void
}
interface SubmenuItem {
key: string
type?: 'item' | 'divider'
icon?: string
label?: string
action?: () => void
disabled?: boolean
visible?: boolean
type?: 'item' | 'divider'
items?: MenuItem[]
}
// Constants
@@ -142,7 +146,7 @@ const EXTERNAL_LINKS = {
DOCS: 'https://docs.comfy.org/',
DISCORD: 'https://www.comfy.org/discord',
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
DESKTOP_GUIDE: 'https://docs.comfy.org/installation/desktop',
DESKTOP_GUIDE: 'https://comfyorg.notion.site/',
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
} as const
@@ -158,12 +162,19 @@ const TIME_UNITS = {
const SUBMENU_CONFIG = {
DELAY_MS: 100,
OFFSET_PX: 8,
Z_INDEX: 1002
Z_INDEX: 10001
} as const
// Composables
const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
// Emits
const emit = defineEmits<{
close: []
}>()
// State
const isSubmenuVisible = ref(false)
@@ -173,67 +184,103 @@ let hoverTimeout: number | null = null
// Computed
const hasReleases = computed(() => releaseStore.releases.length > 0)
const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
const menuItems = computed<MenuItem[]>(() => [
{
key: 'docs',
icon: 'pi pi-book',
label: t('helpCenter.docs'),
action: () => openExternalLink(EXTERNAL_LINKS.DOCS)
},
{
key: 'discord',
icon: 'pi pi-discord',
label: 'Discord',
action: () => openExternalLink(EXTERNAL_LINKS.DISCORD)
},
{
key: 'github',
icon: 'pi pi-github',
label: t('helpCenter.github'),
action: () => openExternalLink(EXTERNAL_LINKS.GITHUB)
},
{
key: 'help',
icon: 'pi pi-question-circle',
label: t('helpCenter.helpFeedback'),
action: () => openExternalLink(EXTERNAL_LINKS.DISCORD)
},
{
key: 'more',
icon: '',
label: t('helpCenter.more'),
action: () => {} // No action for more item
}
])
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
const submenuItems = computed<SubmenuItem[]>(() => [
{
key: 'desktop-guide',
type: 'item',
label: t('helpCenter.desktopUserGuide'),
action: () => openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE),
disabled: false
},
{
key: 'dev-tools',
type: 'item',
label: t('helpCenter.openDevTools'),
action: openDevTools,
disabled: !isElectron()
},
{
key: 'divider-1',
type: 'divider'
},
{
key: 'reinstall',
type: 'item',
label: t('helpCenter.reinstall'),
action: onReinstall,
disabled: !isElectron()
}
])
const menuItems = computed<MenuItem[]>(() => {
const moreItems: MenuItem[] = [
{
key: 'desktop-guide',
type: 'item',
label: t('helpCenter.desktopUserGuide'),
action: () => {
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
emit('close')
}
},
{
key: 'dev-tools',
type: 'item',
label: t('helpCenter.openDevTools'),
visible: isElectron(),
action: () => {
openDevTools()
emit('close')
}
},
{
key: 'divider-1',
type: 'divider',
visible: isElectron()
},
{
key: 'reinstall',
type: 'item',
label: t('helpCenter.reinstall'),
visible: isElectron(),
action: () => {
onReinstall()
emit('close')
}
}
]
return [
{
key: 'docs',
type: 'item',
icon: 'pi pi-book',
label: t('helpCenter.docs'),
action: () => {
openExternalLink(EXTERNAL_LINKS.DOCS)
emit('close')
}
},
{
key: 'discord',
type: 'item',
icon: 'pi pi-discord',
label: 'Discord',
action: () => {
openExternalLink(EXTERNAL_LINKS.DISCORD)
emit('close')
}
},
{
key: 'github',
type: 'item',
icon: 'pi pi-github',
label: t('helpCenter.github'),
action: () => {
openExternalLink(EXTERNAL_LINKS.GITHUB)
emit('close')
}
},
{
key: 'help',
type: 'item',
icon: 'pi pi-question-circle',
label: t('helpCenter.helpFeedback'),
action: () => {
void commandStore.execute('Comfy.Feedback')
emit('close')
}
},
{
key: 'more',
type: 'item',
icon: '',
label: t('helpCenter.more'),
action: () => {}, // No action for more item
items: moreItems
}
]
})
// Utility Functions
const openExternalLink = (url: string): void => {
@@ -251,8 +298,12 @@ const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => {
const rect = button.getBoundingClientRect()
const submenuWidth = 210 // Width defined in CSS
// Get actual submenu height if available, otherwise use estimated height
const submenuHeight = submenuRef.value?.offsetHeight || 120 // More realistic estimate for 2 items
// Get actual submenu height if available, otherwise estimate based on visible item count
const visibleItemCount =
moreMenuItem.value?.items?.filter((item) => item.visible !== false)
.length || 0
const estimatedHeight = visibleItemCount * 48 + 16 // ~48px per item + padding
const submenuHeight = submenuRef.value?.offsetHeight || estimatedHeight
// Get viewport dimensions
const viewportWidth = window.innerWidth
@@ -282,6 +333,8 @@ const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => {
top = SUBMENU_CONFIG.OFFSET_PX
}
top -= 8
return {
position: 'fixed',
top: `${top}px`,
@@ -328,7 +381,13 @@ const onMenuItemHover = async (
key: string,
event: MouseEvent
): Promise<void> => {
if (key !== 'more') return
if (key !== 'more' || !moreMenuItem.value?.items) return
// Don't show submenu if all items are hidden
const hasVisibleItems = moreMenuItem.value.items.some(
(item) => item.visible !== false
)
if (!hasVisibleItems) return
clearHoverTimeout()
@@ -380,10 +439,12 @@ const onReleaseClick = (release: ReleaseNote): void => {
const versionAnchor = formatVersionAnchor(release.version)
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
openExternalLink(changelogUrl)
emit('close')
}
const onUpdate = (_: ReleaseNote): void => {
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
emit('close')
}
// Generate language-aware changelog URL
@@ -551,13 +612,6 @@ onMounted(async () => {
box-shadow: none;
}
.submenu-item.disabled,
.submenu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.submenu-divider {
height: 1px;
background: #3e3e3e;

View File

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

View File

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

View File

@@ -46,7 +46,7 @@
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="#graph-canvas-container">
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@@ -101,14 +101,14 @@ onMounted(async () => {
left: 0;
right: 0;
bottom: 0;
z-index: 999;
z-index: 9999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 1000;
z-index: 10000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ const CORE_NODES_PACK_NAME = 'comfy-core'
export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const nodeDefStore = useNodeDefStore()
const systemStatsStore = useSystemStatsStore()
const { search } = useComfyRegistryStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const workflowPacks = ref<WorkflowPack[]>([])
@@ -70,18 +70,19 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
}
}
// 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]
// Query the registry to find which pack provides this node
const pack = await inferPackFromNodeName.call(nodeName)
if (pack) {
return {
id: pack.id,
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,6 +53,11 @@ export function useSettingSearch() {
const queryLower = query.toLocaleLowerCase()
const allSettings = Object.values(settingStore.settingsById)
const filteredSettings = allSettings.filter((setting) => {
// Filter out hidden and deprecated settings, just like in normal settings tree
if (setting.type === 'hidden' || setting.deprecated) {
return false
}
const idLower = setting.id.toLowerCase()
const nameLower = setting.name.toLowerCase()
const translatedName = st(

View File

@@ -1,5 +1,4 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
@@ -7,13 +6,11 @@ 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: t('sideToolbar.modelLibrary'),
tooltip: t('sideToolbar.modelLibrary'),
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
component: markRaw(ModelLibrarySidebarTab),
type: 'vue',
iconBadge: () => {

View File

@@ -1,16 +1,14 @@
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: t('sideToolbar.nodeLibrary'),
tooltip: t('sideToolbar.nodeLibrary'),
title: 'sideToolbar.nodeLibrary',
tooltip: 'sideToolbar.nodeLibrary',
component: markRaw(NodeLibrarySidebarTab),
type: 'vue'
}

View File

@@ -1,12 +1,10 @@
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',
@@ -15,8 +13,8 @@ export const useQueueSidebarTab = (): SidebarTabExtension => {
const value = queuePendingTaskCountStore.count.toString()
return value === '0' ? null : value
},
title: t('sideToolbar.queue'),
tooltip: t('sideToolbar.queue'),
title: 'sideToolbar.queue',
tooltip: 'sideToolbar.queue',
component: markRaw(QueueSidebarTab),
type: 'vue'
}

View File

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

View File

@@ -10,14 +10,19 @@ import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
function onFloatValueChange(this: INumericWidget, v: number) {
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
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
}
}
export const _for_testing = {
@@ -62,7 +67,7 @@ export const useFloatWidget = () => {
max: inputSpec.max ?? 2048,
round:
enableRounding && precision && !inputSpec.round
? (1_000_000 * Math.pow(0.1, precision)) / 1_000_000
? Math.pow(10, -precision)
: (inputSpec.round as number),
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10.0,

View File

@@ -290,6 +290,7 @@ export const CORE_SETTINGS: SettingParams[] = [
options: [
{ value: 'en', text: 'English' },
{ value: 'zh', text: '中文' },
{ value: 'zh-TW', text: '繁體中文' },
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
@@ -330,6 +331,14 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.20.3'
},
{
id: 'Comfy.Notification.ShowVersionUpdates',
category: ['Comfy', 'Notification Preferences'],
name: 'Show version updates',
tooltip: 'Show updates for new models, and major new features.',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.ConfirmClear',
category: ['Comfy', 'Workflow', 'ConfirmClear'],
@@ -431,6 +440,8 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Use new menu',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom'],
tooltip:
'Menu bar position. On mobile devices, the menu is always shown at the top.',
migrateDeprecatedValue: (value: string) => {
// Floating is now supported by dragging the docked actionbar off.
if (value === 'Floating') {
@@ -747,6 +758,13 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.8.7'
},
{
id: 'Comfy.InstalledVersion',
name: 'The frontend version that was running when the user first installed ComfyUI',
type: 'hidden',
defaultValue: null,
versionAdded: '1.24.0'
},
{
id: 'LiteGraph.ContextMenu.Scaling',
name: 'Scale node combo widget menus (lists) when zoomed in',

View File

@@ -4854,7 +4854,7 @@ class KeyboardManager {
private maskEditor: MaskEditorDialog
private messageBroker: MessageBroker
// Binded functions, for use in addListeners and removeListeners
// Bound functions, for use in addListeners and removeListeners
private handleKeyDownBound = this.handleKeyDown.bind(this)
private handleKeyUpBound = this.handleKeyUp.bind(this)
private clearKeysBound = this.clearKeys.bind(this)

View File

@@ -6,7 +6,7 @@ import type {
LLink,
Vector2
} from '@comfyorg/litegraph'
import type { CanvasMouseEvent } from '@comfyorg/litegraph/dist/types/events'
import type { CanvasPointerEvent } from '@comfyorg/litegraph/dist/types/events'
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import {
@@ -78,7 +78,7 @@ export class PrimitiveNode extends LGraphNode {
app.canvas,
node,
app.canvas.graph_mouse,
{} as CanvasMouseEvent
{} as CanvasPointerEvent
)
}
}

View File

@@ -24,6 +24,10 @@ import ruCommands from './locales/ru/commands.json'
import ru from './locales/ru/main.json'
import ruNodes from './locales/ru/nodeDefs.json'
import ruSettings from './locales/ru/settings.json'
import zhTWCommands from './locales/zh-TW/commands.json'
import zhTW from './locales/zh-TW/main.json'
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
import zhTWSettings from './locales/zh-TW/settings.json'
import zhCommands from './locales/zh/commands.json'
import zh from './locales/zh/main.json'
import zhNodes from './locales/zh/nodeDefs.json'
@@ -41,6 +45,7 @@ function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings),
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings),
ru: buildLocale(ru, ruNodes, ruCommands, ruSettings),
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
ko: buildLocale(ko, koNodes, koCommands, koSettings),

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

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

View File

@@ -14,68 +14,17 @@ Our project supports multiple languages using `vue-i18n`. This allows users arou
## How to Add a New Language
We welcome the addition of new languages. You can add a new language by following these steps:
Want to add a new language to ComfyUI? See our detailed [Contributing Guide](./CONTRIBUTING.md) with step-by-step instructions and confirmed working process.
### 1\. Generate language files
### Quick Start
1. Open an issue or reach out on Discord to request a new language
2. Follow the [technical process](./CONTRIBUTING.md#technical-process-confirmed-working) or ask for help
3. Our CI will automatically generate translations using OpenAI
4. Become a maintainer for your language
We use [lobe-i18n](https://github.com/lobehub/lobe-cli-toolbox/blob/master/packages/lobe-i18n/README.md) as our translation tool, which integrates with LLM for efficient localization.
Update the configuration file to include the new language(s) you wish to add:
```javascript
const { defineConfig } = require('@lobehub/i18n-cli');
module.exports = defineConfig({
entry: 'src/locales/en.json', // Base language file
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'], // Add the new language(s) here
});
```
Set your OpenAI API Key by running the following command:
```sh
npx lobe-i18n --option
```
Once configured, generate the translation files with:
```sh
npx lobe-i18n locale
```
This will create the language files for the specified languages in the configuration.
### 2\. Update i18n Configuration
Import the newly generated locale file(s) in the `src/i18n.ts` file to include them in the application's i18n setup.
### 3\. Enable Selection of the New Language
Add the newly added language to the following item in `src/constants/coreSettings.ts`:
```typescript
{
id: 'Comfy.Locale',
name: 'Locale',
type: 'combo',
// Add the new language(s) here
options: [
{ value: 'en', text: 'English' },
{ value: 'zh', text: '中文' },
{ value: 'ru', text: 'Русский' },
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' }
],
defaultValue: navigator.language.split('-')[0] || 'en'
},
```
This will make the new language selectable in the application's settings.
### 4\. Test the Translations
Start the development server, switch to the new language, and verify the translations. You can switch languages by opening the ComfyUI Settings and selecting from the `ComfyUI > Locale` dropdown box.
### File Structure
Each language has 4 translation files in `src/locales/[language-code]/`:
- `main.json` - Main UI text
- `commands.json` - Command descriptions
- `settings.json` - Settings panel
- `nodeDefs.json` - Node definitions

View File

@@ -197,7 +197,7 @@
"issueReport": {
"submitErrorReport": "Submit Error Report (Optional)",
"provideEmail": "Give us your email (optional)",
"provideAdditionalDetails": "Provide additional details (optional)",
"provideAdditionalDetails": "Provide additional details",
"stackTrace": "Stack Trace",
"systemStats": "System Stats",
"contactFollowUp": "Contact me for follow up",
@@ -950,7 +950,8 @@
"Light": "Light",
"User": "User",
"Credits": "Credits",
"API Nodes": "API Nodes"
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences"
},
"serverConfigItems": {
"listen": {
@@ -1489,6 +1490,7 @@
"loadError": "Failed to load help: {error}"
},
"whatsNewPopup": {
"learnMore": "Learn more"
"learnMore": "Learn more",
"noReleaseNotes": "No release notes available."
}
}

View File

@@ -259,6 +259,10 @@
"name": "Number of nodes suggestions",
"tooltip": "Only for litegraph searchbox/context menu"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "Show version updates",
"tooltip": "Show updates for new models, and major new features."
},
"Comfy_Pointer_ClickBufferTime": {
"name": "Pointer click drift delay",
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking."

View File

@@ -786,9 +786,13 @@
"Toggle Bottom Panel": "Alternar panel inferior",
"Toggle Focus Mode": "Alternar modo de enfoque",
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
"Toggle Model Library Sidebar": "Alternar barra lateral de la biblioteca de modelos",
"Toggle Node Library Sidebar": "Alternar barra lateral de la biblioteca de nodos",
"Toggle Queue Sidebar": "Alternar barra lateral de la cola",
"Toggle Search Box": "Alternar caja de búsqueda",
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
"Toggle Workflows Sidebar": "Alternar barra lateral de los flujos de trabajo",
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
@@ -1100,6 +1104,7 @@
"Node Search Box": "Caja de Búsqueda de Nodo",
"Node Widget": "Widget de Nodo",
"NodeLibrary": "Biblioteca de Nodos",
"Notification Preferences": "Preferencias de notificación",
"Pointer": "Puntero",
"Queue": "Cola",
"QueueButton": "Botón de Cola",
@@ -1484,11 +1489,12 @@
"title": "Bienvenido a ComfyUI"
},
"whatsNewPopup": {
"learnMore": "Aprende más"
"learnMore": "Aprende más",
"noReleaseNotes": "No hay notas de la versión disponibles."
},
"workflowService": {
"enterFilename": "Introduzca el nombre del archivo",
"exportWorkflow": "Exportar flujo de trabajo",
"saveWorkflow": "Guardar flujo de trabajo"
}
}
}

View File

@@ -259,6 +259,10 @@
"name": "Destacar nodo de ajuste",
"tooltip": "Al arrastrar un enlace sobre un nodo con ranura de entrada viable, resalta el nodo"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "Mostrar actualizaciones de versión",
"tooltip": "Mostrar actualizaciones para nuevos modelos y funciones principales nuevas."
},
"Comfy_Pointer_ClickBufferTime": {
"name": "Retraso de deriva del clic del puntero",
"tooltip": "Después de presionar un botón del puntero, este es el tiempo máximo (en milisegundos) que se puede ignorar el movimiento del puntero.\n\nAyuda a prevenir que los objetos sean movidos involuntariamente si el puntero se mueve al hacer clic."

View File

@@ -786,9 +786,13 @@
"Toggle Bottom Panel": "Basculer le panneau inférieur",
"Toggle Focus Mode": "Basculer le mode focus",
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de nœuds",
"Toggle Queue Sidebar": "Afficher/Masquer la barre latérale de la file dattente",
"Toggle Search Box": "Basculer la boîte de recherche",
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle Workflows Sidebar": "Afficher/Masquer la barre latérale des workflows",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
@@ -1100,6 +1104,7 @@
"Node Search Box": "Boîte de Recherche de Nœud",
"Node Widget": "Widget de Nœud",
"NodeLibrary": "Bibliothèque de Nœuds",
"Notification Preferences": "Préférences de notification",
"Pointer": "Pointeur",
"Queue": "File d'Attente",
"QueueButton": "Bouton de File d'Attente",
@@ -1484,11 +1489,12 @@
"title": "Bienvenue sur ComfyUI"
},
"whatsNewPopup": {
"learnMore": "En savoir plus"
"learnMore": "En savoir plus",
"noReleaseNotes": "Aucune note de version disponible."
},
"workflowService": {
"enterFilename": "Entrez le nom du fichier",
"exportWorkflow": "Exporter le flux de travail",
"saveWorkflow": "Enregistrer le flux de travail"
}
}
}

View File

@@ -259,6 +259,10 @@
"name": "Le snap met en évidence le nœud",
"tooltip": "Lorsque vous faites glisser un lien sur un nœud avec une fente d'entrée viable, mettez en évidence le nœud"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "Afficher les mises à jour de version",
"tooltip": "Afficher les mises à jour pour les nouveaux modèles et les nouvelles fonctionnalités majeures."
},
"Comfy_Pointer_ClickBufferTime": {
"name": "Délai de dérive du clic du pointeur",
"tooltip": "Après avoir appuyé sur un bouton de pointeur, c'est le temps maximum (en millisecondes) que le mouvement du pointeur peut être ignoré.\n\nAide à prévenir que les objets soient déplacés involontairement si le pointeur est déplacé lors du clic."

View File

@@ -1100,6 +1100,7 @@
"Node Search Box": "ノード検索ボックス",
"Node Widget": "ノードウィジェット",
"NodeLibrary": "ノードライブラリ",
"Notification Preferences": "通知設定",
"Pointer": "ポインタ",
"Queue": "キュー",
"QueueButton": "キューボタン",
@@ -1484,7 +1485,8 @@
"title": "ComfyUIへようこそ"
},
"whatsNewPopup": {
"learnMore": "詳細はこちら"
"learnMore": "詳細はこちら",
"noReleaseNotes": "リリースノートはありません。"
},
"workflowService": {
"enterFilename": "ファイル名を入力",

View File

@@ -259,6 +259,10 @@
"name": "スナップハイライトノード",
"tooltip": "有効な入力スロットを持つノードの上にリンクをドラッグすると、ノードがハイライトされます"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "バージョン更新を表示",
"tooltip": "新しいモデルや主要な新機能のアップデートを表示します。"
},
"Comfy_Pointer_ClickBufferTime": {
"name": "ポインタークリックドリフト遅延",
"tooltip": "ポインターボタンを押した後、ポインタの動きが無視される最大時間(ミリ秒単位)です。\n\nクリック中にポインタが移動した場合、オブジェクトが意図せず動かされるのを防ぎます。"

View File

@@ -786,9 +786,13 @@
"Toggle Bottom Panel": "하단 패널 전환",
"Toggle Focus Mode": "포커스 모드 전환",
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Queue Sidebar": "대기열 사이드바 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
@@ -1100,6 +1104,7 @@
"Node Search Box": "노드 검색 상자",
"Node Widget": "노드 위젯",
"NodeLibrary": "노드 라이브러리",
"Notification Preferences": "알림 환경설정",
"Pointer": "포인터",
"Queue": "실행 대기열",
"QueueButton": "실행 대기열 버튼",
@@ -1484,11 +1489,12 @@
"title": "ComfyUI에 오신 것을 환영합니다"
},
"whatsNewPopup": {
"learnMore": "자세히 알아보기"
"learnMore": "자세히 알아보기",
"noReleaseNotes": "릴리스 노트가 없습니다."
},
"workflowService": {
"enterFilename": "파일 이름 입력",
"exportWorkflow": "워크플로 내보내기",
"saveWorkflow": "워크플로 저장"
}
}
}

View File

@@ -259,6 +259,10 @@
"name": "스냅 하이라이트 노드",
"tooltip": "링크를 유효한 입력 슬롯이 있는 노드 위로 드래그할 때 노드를 강조 표시합니다."
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "버전 업데이트 표시",
"tooltip": "새 모델과 주요 신규 기능에 대한 업데이트를 표시합니다."
},
"Comfy_Pointer_ClickBufferTime": {
"name": "포인터 클릭 드리프트 지연",
"tooltip": "포인터 버튼을 누른 후, 포인터 움직임을 무시할 수 있는 최대 시간(밀리초)입니다.\n\n클릭하는 동안 포인터가 움직여 의도치 않게 객체가 밀리는 것을 방지합니다."

View File

@@ -786,9 +786,13 @@
"Toggle Bottom Panel": "Переключить нижнюю панель",
"Toggle Focus Mode": "Переключить режим фокуса",
"Toggle Logs Bottom Panel": "Переключение нижней панели журналов",
"Toggle Model Library Sidebar": "Показать/скрыть боковую панель библиотеки моделей",
"Toggle Node Library Sidebar": "Показать/скрыть боковую панель библиотеки узлов",
"Toggle Queue Sidebar": "Показать/скрыть боковую панель очереди",
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
"Toggle Workflows Sidebar": "Показать/скрыть боковую панель рабочих процессов",
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
@@ -1100,6 +1104,7 @@
"Node Search Box": "Поисковая строка нод",
"Node Widget": "Виджет ноды",
"NodeLibrary": "Библиотека нод",
"Notification Preferences": "Настройки уведомлений",
"Pointer": "Указатель",
"Queue": "Очередь",
"QueueButton": "Кнопка очереди",
@@ -1484,11 +1489,12 @@
"title": "Добро пожаловать в ComfyUI"
},
"whatsNewPopup": {
"learnMore": "Узнать больше"
"learnMore": "Узнать больше",
"noReleaseNotes": "Нет доступных примечаний к выпуску."
},
"workflowService": {
"enterFilename": "Введите название файла",
"exportWorkflow": "Экспорт рабочего процесса",
"saveWorkflow": "Сохранить рабочий процесс"
}
}
}

View File

@@ -259,6 +259,10 @@
"name": "Подсветка ноды при привязке",
"tooltip": "При перетаскивании ссылки над нодой с подходящим входным слотом, нода подсвечивается"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "Показывать обновления версий",
"tooltip": "Показывать обновления новых моделей и основные новые функции."
},
"Comfy_Pointer_ClickBufferTime": {
"name": "Задержка дрейфа щелчка указателя",
"tooltip": "После нажатия кнопки указателя, это максимальное время (в миллисекундах), в течение которого движение указателя может быть проигнорировано.\n\nПомогает предотвратить непреднамеренное смещение объектов, если указатель перемещается во время щелчка."

View File

@@ -0,0 +1,249 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "檢查更新"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "開啟自訂節點資料夾"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "開啟輸入資料夾"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "開啟日誌資料夾"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "開啟 extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "開啟模型資料夾"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "開啟輸出資料夾"
},
"Comfy-Desktop_OpenDevTools": {
"label": "開啟開發者工具"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "桌面版使用指南"
},
"Comfy-Desktop_Quit": {
"label": "退出"
},
"Comfy-Desktop_Reinstall": {
"label": "重新安裝"
},
"Comfy-Desktop_Restart": {
"label": "重新啟動"
},
"Comfy_BrowseTemplates": {
"label": "瀏覽範本"
},
"Comfy_Canvas_AddEditModelStep": {
"label": "新增編輯模型步驟"
},
"Comfy_Canvas_DeleteSelectedItems": {
"label": "刪除選取項目"
},
"Comfy_Canvas_FitView": {
"label": "將視圖適應至所選節點"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "將選取的節點下移"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "左移選取的節點"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "右移選取的節點"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "上移選取的節點"
},
"Comfy_Canvas_ResetView": {
"label": "重設視圖"
},
"Comfy_Canvas_Resize": {
"label": "調整所選節點大小"
},
"Comfy_Canvas_ToggleLinkVisibility": {
"label": "畫布切換連結可見性"
},
"Comfy_Canvas_ToggleLock": {
"label": "畫布切換鎖定"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "略過/取消略過選取的節點"
},
"Comfy_Canvas_ToggleSelectedNodes_Collapse": {
"label": "收合/展開選取的節點"
},
"Comfy_Canvas_ToggleSelectedNodes_Mute": {
"label": "停用/啟用選取的節點"
},
"Comfy_Canvas_ToggleSelectedNodes_Pin": {
"label": "釘選/取消釘選已選取的節點"
},
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "釘選/取消釘選已選項目"
},
"Comfy_Canvas_ZoomIn": {
"label": "放大"
},
"Comfy_Canvas_ZoomOut": {
"label": "縮小"
},
"Comfy_ClearPendingTasks": {
"label": "清除待處理任務"
},
"Comfy_ClearWorkflow": {
"label": "清除工作流程"
},
"Comfy_ContactSupport": {
"label": "聯絡支援"
},
"Comfy_DuplicateWorkflow": {
"label": "複製目前工作流程"
},
"Comfy_ExportWorkflow": {
"label": "匯出工作流程"
},
"Comfy_ExportWorkflowAPI": {
"label": "匯出工作流程API 格式)"
},
"Comfy_Feedback": {
"label": "提供回饋"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "將選取內容轉換為子圖"
},
"Comfy_Graph_FitGroupToContents": {
"label": "調整群組以符合內容"
},
"Comfy_Graph_GroupSelectedNodes": {
"label": "群組所選節點"
},
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
"label": "將選取的節點轉換為群組節點"
},
"Comfy_GroupNode_ManageGroupNodes": {
"label": "管理群組節點"
},
"Comfy_GroupNode_UngroupSelectedGroupNodes": {
"label": "取消群組所選群組節點"
},
"Comfy_Help_AboutComfyUI": {
"label": "開啟關於 ComfyUI"
},
"Comfy_Help_OpenComfyOrgDiscord": {
"label": "開啟 Comfy-Org Discord"
},
"Comfy_Help_OpenComfyUIDocs": {
"label": "開啟 ComfyUI 文件"
},
"Comfy_Help_OpenComfyUIForum": {
"label": "開啟 ComfyUI 論壇"
},
"Comfy_Help_OpenComfyUIIssues": {
"label": "開啟 ComfyUI 問題追蹤"
},
"Comfy_Interrupt": {
"label": "中斷"
},
"Comfy_LoadDefaultWorkflow": {
"label": "載入預設工作流程"
},
"Comfy_Manager_CustomNodesManager": {
"label": "切換自訂節點管理器"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切換自訂節點管理器進度條"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "為選取的節點開啟 Mask 編輯器"
},
"Comfy_NewBlankWorkflow": {
"label": "新增空白工作流程"
},
"Comfy_OpenClipspace": {
"label": "Clipspace"
},
"Comfy_OpenWorkflow": {
"label": "開啟工作流程"
},
"Comfy_QueuePrompt": {
"label": "將提示詞加入佇列"
},
"Comfy_QueuePromptFront": {
"label": "將提示詞加入佇列前方"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "佇列所選的輸出節點"
},
"Comfy_Redo": {
"label": "重做"
},
"Comfy_RefreshNodeDefinitions": {
"label": "重新整理節點定義"
},
"Comfy_SaveWorkflow": {
"label": "儲存工作流程"
},
"Comfy_SaveWorkflowAs": {
"label": "另存工作流程"
},
"Comfy_ShowSettingsDialog": {
"label": "顯示設定對話框"
},
"Comfy_ToggleTheme": {
"label": "切換主題(深色/淺色)"
},
"Comfy_Undo": {
"label": "復原"
},
"Comfy_User_OpenSignInDialog": {
"label": "開啟登入對話框"
},
"Comfy_User_SignOut": {
"label": "登出"
},
"Workspace_CloseWorkflow": {
"label": "關閉當前工作流程"
},
"Workspace_NextOpenedWorkflow": {
"label": "下一個已開啟的工作流程"
},
"Workspace_PreviousOpenedWorkflow": {
"label": "上次開啟的工作流程"
},
"Workspace_SearchBox_Toggle": {
"label": "切換搜尋框"
},
"Workspace_ToggleBottomPanel": {
"label": "切換下方面板"
},
"Workspace_ToggleBottomPanelTab_command-terminal": {
"label": "切換終端機底部面板"
},
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "切換日誌底部面板"
},
"Workspace_ToggleFocusMode": {
"label": "切換專注模式"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "切換模型庫側邊欄",
"tooltip": "模型庫"
},
"Workspace_ToggleSidebarTab_node-library": {
"label": "切換節點庫側邊欄",
"tooltip": "節點庫"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "切換佇列側邊欄",
"tooltip": "佇列"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "切換工作流程側邊欄",
"tooltip": "工作流程"
}
}

1495
src/locales/zh-TW/main.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,412 @@
{
"Comfy-Desktop_AutoUpdate": {
"name": "自動檢查更新"
},
"Comfy-Desktop_SendStatistics": {
"name": "傳送匿名使用統計資料"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "PyPI 安裝鏡像站",
"tooltip": "預設 pip 安裝鏡像站"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python 安裝鏡像站",
"tooltip": "受管理的 Python 安裝檔會從 Astral 的 python-build-standalone 專案下載。這個變數可以設定為鏡像站的 URL以便從不同來源下載 Python 安裝檔。所提供的 URL 會取代 https://github.com/astral-sh/python-build-standalone/releases/download例如https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz。若要從本機目錄讀取發行版本請使用 file:// URL 格式。"
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch 安裝鏡像站",
"tooltip": "PyTorch 的 pip 安裝鏡像站"
},
"Comfy-Desktop_WindowStyle": {
"name": "視窗樣式",
"options": {
"custom": "自訂",
"default": "預設"
},
"tooltip": "自訂:以 ComfyUI 的頂部選單取代系統標題列"
},
"Comfy_Canvas_BackgroundImage": {
"name": "畫布背景圖片",
"tooltip": "畫布背景的圖片網址。你可以在輸出面板中右鍵點擊圖片並選擇「設為背景」來使用,或是使用上傳按鈕上傳你自己的圖片。"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "顯示選取工具箱"
},
"Comfy_ConfirmClear": {
"name": "清除工作流程時需要確認"
},
"Comfy_DOMClippingEnabled": {
"name": "啟用 DOM 元素裁剪(啟用後可能會降低效能)"
},
"Comfy_DevMode": {
"name": "啟用開發者模式選項API 儲存等)"
},
"Comfy_DisableFloatRounding": {
"name": "停用預設浮點數元件四捨五入。",
"tooltip": "(需重新載入頁面)當後端節點已設定四捨五入時,無法停用四捨五入。"
},
"Comfy_DisableSliders": {
"name": "停用節點元件滑桿"
},
"Comfy_EditAttention_Delta": {
"name": "Ctrl+上/下 精確調整"
},
"Comfy_EnableTooltips": {
"name": "啟用工具提示"
},
"Comfy_EnableWorkflowViewRestore": {
"name": "在工作流程中儲存並還原畫布位置與縮放等級"
},
"Comfy_FloatRoundingPrecision": {
"name": "浮點元件小數點位數 [0 = 自動]。",
"tooltip": "(需重新載入頁面)"
},
"Comfy_Graph_CanvasInfo": {
"name": "在左下角顯示畫布資訊fps 等)"
},
"Comfy_Graph_CanvasMenu": {
"name": "顯示圖形畫布選單"
},
"Comfy_Graph_CtrlShiftZoom": {
"name": "啟用快速縮放快捷鍵Ctrl + Shift + 拖曳)"
},
"Comfy_Graph_LinkMarkers": {
"name": "連結中點標記",
"options": {
"Arrow": "箭頭",
"Circle": "圓圈",
"None": "無"
}
},
"Comfy_Graph_ZoomSpeed": {
"name": "畫布縮放速度"
},
"Comfy_GroupSelectedNodes_Padding": {
"name": "群組所選節點間距"
},
"Comfy_Group_DoubleClickTitleToEdit": {
"name": "雙擊群組標題以編輯"
},
"Comfy_LinkRelease_Action": {
"name": "釋放連結時的動作(無修飾鍵)",
"options": {
"context menu": "右鍵選單",
"no action": "無動作",
"search box": "搜尋框"
}
},
"Comfy_LinkRelease_ActionShift": {
"name": "連結釋放時的動作Shift",
"options": {
"context menu": "右鍵選單",
"no action": "無動作",
"search box": "搜尋框"
}
},
"Comfy_LinkRenderMode": {
"name": "連結渲染模式",
"options": {
"Hidden": "隱藏",
"Linear": "線性",
"Spline": "曲線",
"Straight": "直線"
}
},
"Comfy_Load3D_BackgroundColor": {
"name": "初始背景顏色",
"tooltip": "控制 3D 場景的預設背景顏色。此設定決定新建立 3D 元件時的背景外觀,但每個元件在建立後都可單獨調整。"
},
"Comfy_Load3D_CameraType": {
"name": "初始相機類型",
"options": {
"orthographic": "正交",
"perspective": "透視"
},
"tooltip": "控制新建 3D 元件時,相機預設為透視或正交。此預設值在建立後仍可針對每個元件單獨切換。"
},
"Comfy_Load3D_LightAdjustmentIncrement": {
"name": "燈光調整增量",
"tooltip": "控制在 3D 場景中調整燈光強度時的增量大小。較小的步進值可讓您更細緻地調整燈光,較大的值則每次調整會有更明顯的變化。"
},
"Comfy_Load3D_LightIntensity": {
"name": "初始光源強度",
"tooltip": "設定 3D 場景中燈光的預設亮度等級。此數值決定新建立 3D 元件時燈光照亮物體的強度,但每個元件在建立後都可以個別調整。"
},
"Comfy_Load3D_LightIntensityMaximum": {
"name": "最大光照強度",
"tooltip": "設定 3D 場景中允許的最大光照強度值。這會定義在調整任何 3D 小工具照明時可設定的最高亮度上限。"
},
"Comfy_Load3D_LightIntensityMinimum": {
"name": "光源強度下限",
"tooltip": "設定 3D 場景中允許的最小光源強度值。這會定義在調整任何 3D 控制元件照明時可設定的最低亮度限制。"
},
"Comfy_Load3D_ShowGrid": {
"name": "初始網格可見性",
"tooltip": "控制在建立新的 3D 元件時,網格是否預設可見。此預設值在建立後仍可針對每個元件單獨切換。"
},
"Comfy_Load3D_ShowPreview": {
"name": "初始預覽可見性",
"tooltip": "控制當新建 3D 元件時,預覽畫面預設是否顯示。此預設值在元件建立後仍可針對每個元件單獨切換。"
},
"Comfy_Locale": {
"name": "語言"
},
"Comfy_MaskEditor_BrushAdjustmentSpeed": {
"name": "筆刷調整速度倍數",
"tooltip": "控制調整筆刷大小與硬度時的變化速度。數值越高,變化越快。"
},
"Comfy_MaskEditor_UseDominantAxis": {
"name": "鎖定筆刷調整至主軸",
"tooltip": "啟用後,筆刷調整只會根據你移動較多的方向,分別影響大小或硬度"
},
"Comfy_MaskEditor_UseNewEditor": {
"name": "使用新遮罩編輯器",
"tooltip": "切換到新遮罩編輯器介面"
},
"Comfy_ModelLibrary_AutoLoadAll": {
"name": "自動載入所有模型資料夾",
"tooltip": "若為開啟,當你打開模型庫時,所有資料夾將自動載入(這可能會導致載入時延遲)。若為關閉,只有在你點擊根目錄下的模型資料夾時才會載入。"
},
"Comfy_ModelLibrary_NameFormat": {
"name": "在模型庫樹狀檢視中顯示的名稱",
"options": {
"filename": "filename",
"title": "title"
},
"tooltip": "選擇「filename」可在模型清單中顯示簡化的原始檔名不含目錄或「.safetensors」副檔名。選擇「title」則顯示可設定的模型中繼資料標題。"
},
"Comfy_NodeBadge_NodeIdBadgeMode": {
"name": "節點 ID 標籤模式",
"options": {
"None": "無",
"Show all": "全部顯示"
}
},
"Comfy_NodeBadge_NodeLifeCycleBadgeMode": {
"name": "節點生命週期徽章模式",
"options": {
"None": "無",
"Show all": "顯示全部"
}
},
"Comfy_NodeBadge_NodeSourceBadgeMode": {
"name": "節點來源徽章模式",
"options": {
"Hide built-in": "隱藏內建",
"None": "無",
"Show all": "全部顯示"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "顯示 API 節點價格標籤"
},
"Comfy_NodeSearchBoxImpl": {
"name": "節點搜尋框實作",
"options": {
"default": "預設",
"litegraph (legacy)": "litegraph舊版"
}
},
"Comfy_NodeSearchBoxImpl_NodePreview": {
"name": "節點預覽",
"tooltip": "僅適用於預設實作"
},
"Comfy_NodeSearchBoxImpl_ShowCategory": {
"name": "在搜尋結果中顯示節點分類",
"tooltip": "僅適用於預設實作"
},
"Comfy_NodeSearchBoxImpl_ShowIdName": {
"name": "在搜尋結果中顯示節點 ID 名稱",
"tooltip": "僅適用於預設實作"
},
"Comfy_NodeSearchBoxImpl_ShowNodeFrequency": {
"name": "在搜尋結果中顯示節點頻率",
"tooltip": "僅適用於預設實作"
},
"Comfy_NodeSuggestions_number": {
"name": "節點建議數量",
"tooltip": "僅適用於 litegraph 搜尋框/右鍵選單"
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "在圖片預覽下方顯示寬度 × 高度"
},
"Comfy_Node_AutoSnapLinkToSlot": {
"name": "自動吸附連結到節點插槽",
"tooltip": "拖曳連結到節點時,連結會自動吸附到節點上可用的輸入插槽"
},
"Comfy_Node_BypassAllLinksOnDelete": {
"name": "刪除節點時保留所有連結",
"tooltip": "刪除節點時,嘗試自動重新連接其所有輸入與輸出連結(繞過被刪除的節點)"
},
"Comfy_Node_DoubleClickTitleToEdit": {
"name": "雙擊節點標題以編輯"
},
"Comfy_Node_MiddleClickRerouteNode": {
"name": "中鍵點擊建立新的重導節點"
},
"Comfy_Node_Opacity": {
"name": "節點不透明度"
},
"Comfy_Node_ShowDeprecated": {
"name": "在搜尋中顯示已棄用節點",
"tooltip": "已棄用的節點在介面中預設隱藏,但在現有使用這些節點的工作流程中仍可運作。"
},
"Comfy_Node_ShowExperimental": {
"name": "在搜尋中顯示實驗性節點",
"tooltip": "實驗性節點會在介面中標註,未來版本可能會有重大變動或被移除。請在正式工作流程中謹慎使用"
},
"Comfy_Node_SnapHighlightsNode": {
"name": "節點高亮顯示對齊",
"tooltip": "當拖曳連結到具有可用輸入插槽的節點時,高亮顯示該節點"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "顯示版本更新",
"tooltip": "顯示新模型和主要新功能的更新。"
},
"Comfy_Pointer_ClickBufferTime": {
"name": "指標點擊漂移延遲",
"tooltip": "按下指標按鈕後,這是可忽略指標移動的最長時間(以毫秒為單位)。\n\n可防止在點擊時移動指標導致物件被意外推動。"
},
"Comfy_Pointer_ClickDrift": {
"name": "指標點擊漂移(最大距離)",
"tooltip": "如果在按住按鈕時指標移動超過此距離,則視為拖曳(而非點擊)。\n\n可防止在點擊時不小心移動指標導致物件被意外推動。"
},
"Comfy_Pointer_DoubleClickTime": {
"name": "雙擊間隔(最大值)",
"tooltip": "兩次點擊被視為雙擊的最長間隔時間(毫秒)。增加此數值可協助在雙擊有時未被辨識時改善操作體驗。"
},
"Comfy_PreviewFormat": {
"name": "預覽圖片格式",
"tooltip": "在圖片元件中顯示預覽時,將其轉換為輕量級圖片格式,例如 webp、jpeg、webp;50 等。"
},
"Comfy_PromptFilename": {
"name": "儲存工作流程時提示輸入檔案名稱"
},
"Comfy_QueueButton_BatchCountLimit": {
"name": "批次數量上限",
"tooltip": "每次按鈕點擊可加入佇列的最大任務數"
},
"Comfy_Queue_MaxHistoryItems": {
"name": "佇列歷史記錄大小",
"tooltip": "佇列歷史中顯示的最大任務數量。"
},
"Comfy_Sidebar_Location": {
"name": "側邊欄位置",
"options": {
"left": "左側",
"right": "右側"
}
},
"Comfy_Sidebar_Size": {
"name": "側邊欄大小",
"options": {
"normal": "一般",
"small": "小"
}
},
"Comfy_Sidebar_UnifiedWidth": {
"name": "統一側邊欄寬度"
},
"Comfy_SnapToGrid_GridSize": {
"name": "對齊至格線大小",
"tooltip": "當按住 Shift 拖曳或調整節點大小時,節點會對齊到格線,此設定可調整格線的大小。"
},
"Comfy_TextareaWidget_FontSize": {
"name": "文字區塊元件字型大小"
},
"Comfy_TextareaWidget_Spellcheck": {
"name": "文字方塊小工具拼字檢查"
},
"Comfy_TreeExplorer_ItemPadding": {
"name": "樹狀瀏覽器項目間距"
},
"Comfy_UseNewMenu": {
"name": "使用新選單",
"options": {
"Bottom": "下方",
"Disabled": "停用",
"Top": "上方"
}
},
"Comfy_Validation_NodeDefs": {
"name": "驗證節點定義(較慢)",
"tooltip": "建議節點開發者使用。這會在啟動時驗證所有節點定義。"
},
"Comfy_Validation_Workflows": {
"name": "驗證工作流程"
},
"Comfy_WidgetControlMode": {
"name": "元件控制模式",
"options": {
"after": "佇列後",
"before": "佇列前"
},
"tooltip": "控制元件數值何時更新(隨機、遞增、遞減),可選在提示加入佇列前或後進行。"
},
"Comfy_Window_UnloadConfirmation": {
"name": "關閉視窗時顯示確認提示"
},
"Comfy_Workflow_AutoSave": {
"name": "自動儲存",
"options": {
"after delay": "延遲後",
"off": "關閉"
}
},
"Comfy_Workflow_AutoSaveDelay": {
"name": "自動儲存延遲(毫秒)",
"tooltip": "僅在自動儲存設為「延遲後」時適用。"
},
"Comfy_Workflow_ConfirmDelete": {
"name": "刪除工作流程時顯示確認視窗"
},
"Comfy_Workflow_Persist": {
"name": "保留工作流程狀態並於頁面重新載入時還原"
},
"Comfy_Workflow_ShowMissingModelsWarning": {
"name": "顯示缺少模型警告"
},
"Comfy_Workflow_ShowMissingNodesWarning": {
"name": "顯示缺少節點警告"
},
"Comfy_Workflow_SortNodeIdOnSave": {
"name": "儲存工作流程時排序節點 ID"
},
"Comfy_Workflow_WorkflowTabsPosition": {
"name": "已開啟工作流程的位置",
"options": {
"Sidebar": "側邊欄",
"Topbar": "頂部欄",
"Topbar (2nd-row)": "頂部欄(第二列)"
}
},
"LiteGraph_Canvas_LowQualityRenderingZoomThreshold": {
"name": "低品質渲染縮放臨界值",
"tooltip": "當縮小檢視時以低品質渲染圖形"
},
"LiteGraph_Canvas_MaximumFps": {
"name": "最大FPS",
"tooltip": "畫布允許渲染的最大每秒幀數。限制GPU使用率但可能影響流暢度。若設為0則使用螢幕的更新率。預設值0"
},
"LiteGraph_ContextMenu_Scaling": {
"name": "放大時縮放節點組合小工具選單(清單)"
},
"LiteGraph_Node_DefaultPadding": {
"name": "新增節點時自動縮小",
"tooltip": "建立節點時自動調整為最小尺寸。若停用,新增的節點會略為加寬以顯示元件數值。"
},
"LiteGraph_Node_TooltipDelay": {
"name": "提示延遲"
},
"LiteGraph_Pointer_TrackpadGestures": {
"name": "啟用觸控板手勢",
"tooltip": "此設定可為畫布啟用觸控板模式,允許使用兩指縮放與平移。"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "重導樣條偏移",
"tooltip": "貝茲控制點相對於重導中心點的偏移量"
},
"pysssss_SnapToGrid": {
"name": "總是對齊格線"
}
}

View File

@@ -1100,6 +1100,7 @@
"Node Search Box": "节点搜索框",
"Node Widget": "节点组件",
"NodeLibrary": "节点库",
"Notification Preferences": "通知偏好",
"Pointer": "指针",
"Queue": "队列",
"QueueButton": "执行按钮",
@@ -1484,7 +1485,8 @@
"title": "欢迎使用 ComfyUI"
},
"whatsNewPopup": {
"learnMore": "了解更多"
"learnMore": "了解更多",
"noReleaseNotes": "暂无更新说明。"
},
"workflowService": {
"enterFilename": "输入文件名",

View File

@@ -259,6 +259,10 @@
"name": "吸附高亮节点",
"tooltip": "在拖动连线经过具有可用输入接口的节点时,高亮显示该节点。"
},
"Comfy_Notification_ShowVersionUpdates": {
"name": "显示版本更新",
"tooltip": "显示新模型和主要新功能的更新。"
},
"Comfy_Pointer_ClickBufferTime": {
"name": "指针点击漂移延迟",
"tooltip": "按下指针按钮后,忽略指针移动的最大时间(毫秒)。\n\n有助于防止在点击时意外移动鼠标。"

View File

@@ -447,6 +447,7 @@ const zSettings = z.object({
'Comfy.NodeBadge.NodeIdBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.NodeLifeCycleBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.ShowApiPricing': z.boolean(),
'Comfy.Notification.ShowVersionUpdates': z.boolean(),
'Comfy.QueueButton.BatchCountLimit': z.number(),
'Comfy.Queue.MaxHistoryItems': z.number(),
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),
@@ -472,6 +473,7 @@ const zSettings = z.object({
'Comfy.Toast.DisableReconnectingToast': z.boolean(),
'Comfy.Workflow.Persist': z.boolean(),
'Comfy.TutorialCompleted': z.boolean(),
'Comfy.InstalledVersion': z.string().nullable(),
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(),

View File

@@ -65,7 +65,9 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
/**
* @deprecated Use `settingStore.getDefaultValue` instead.
*/
getSettingDefaultValue<K extends keyof Settings>(id: K): Settings[K] {
getSettingDefaultValue<K extends keyof Settings>(
id: K
): Settings[K] | undefined {
return useSettingStore().getDefaultValue(id)
}

View File

@@ -318,6 +318,47 @@ export const useComfyRegistryService = () => {
)
}
/**
* Get the node pack that contains a specific ComfyUI node by its name.
* This method queries the registry to find which pack provides the given node.
*
* When multiple packs contain a node with the same name, the API returns the best match based on:
* 1. Preemption match - If the node name matches any in the pack's preempted_comfy_node_names array
* 2. Search ranking - Lower search_ranking values are preferred
* 3. Total installs - Higher installation counts are preferred as a tiebreaker
*
* @param nodeName - The name of the ComfyUI node (e.g., 'KSampler', 'CLIPTextEncode')
* @param signal - Optional AbortSignal for request cancellation
* @returns The node pack containing the specified node, or null if not found or on error
*
* @example
* ```typescript
* const pack = await inferPackFromNodeName('KSampler')
* if (pack) {
* console.log(`Node found in pack: ${pack.name}`)
* }
* ```
*/
const inferPackFromNodeName = async (
nodeName: operations['getNodeByComfyNodeName']['parameters']['path']['comfyNodeName'],
signal?: AbortSignal
) => {
const endpoint = `/comfy-nodes/${nodeName}/node`
const errorContext = 'Failed to infer pack from comfy node name'
const routeSpecificErrors = {
404: `Comfy node not found: The node with name ${nodeName} does not exist in the registry`
}
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['Node']>(endpoint, {
signal
}),
errorContext,
routeSpecificErrors
)
}
return {
isLoading,
error,
@@ -330,6 +371,7 @@ export const useComfyRegistryService = () => {
getPublisherById,
listPacksForPublisher,
getNodeDefs,
postPackReview
postPackReview,
inferPackFromNodeName
}
}

View File

@@ -0,0 +1,74 @@
import type { useSettingStore } from '@/stores/settingStore'
let pendingCallbacks: Array<() => Promise<void>> = []
let isNewUserDetermined = false
let isNewUserCached: boolean | null = null
export const newUserService = () => {
function checkIsNewUser(
settingStore: ReturnType<typeof useSettingStore>
): boolean {
const isNewUserSettings =
Object.keys(settingStore.settingValues).length === 0 ||
!settingStore.get('Comfy.TutorialCompleted')
const hasNoWorkflow = !localStorage.getItem('workflow')
const hasNoPreviousWorkflow = !localStorage.getItem(
'Comfy.PreviousWorkflow'
)
return isNewUserSettings && hasNoWorkflow && hasNoPreviousWorkflow
}
async function registerInitCallback(callback: () => Promise<void>) {
if (isNewUserDetermined) {
if (isNewUserCached) {
try {
await callback()
} catch (error) {
console.error('New user initialization callback failed:', error)
}
}
} else {
pendingCallbacks.push(callback)
}
}
async function initializeIfNewUser(
settingStore: ReturnType<typeof useSettingStore>
) {
if (isNewUserDetermined) return
isNewUserCached = checkIsNewUser(settingStore)
isNewUserDetermined = true
if (!isNewUserCached) {
pendingCallbacks = []
return
}
await settingStore.set(
'Comfy.InstalledVersion',
__COMFYUI_FRONTEND_VERSION__
)
for (const callback of pendingCallbacks) {
try {
await callback()
} catch (error) {
console.error('New user initialization callback failed:', error)
}
}
pendingCallbacks = []
}
function isNewUser(): boolean | null {
return isNewUserDetermined ? isNewUserCached : null
}
return {
registerInitCallback,
initializeIfNewUser,
isNewUser
}
}

View File

@@ -104,6 +104,17 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
ListPacksResult
>(registryService.search, { maxSize: PACK_LIST_CACHE_SIZE })
/**
* Get the node pack that contains a specific ComfyUI node by its name.
* Results are cached to avoid redundant API calls.
*
* @see {@link useComfyRegistryService.inferPackFromNodeName} for details on the ranking algorithm
*/
const inferPackFromNodeName = useCachedRequest<
operations['getNodeByComfyNodeName']['parameters']['path']['comfyNodeName'],
NodePack
>(registryService.inferPackFromNodeName, { maxSize: PACK_BY_ID_CACHE_SIZE })
/**
* Clear all cached data
*/
@@ -111,6 +122,7 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
getNodeDefs.clear()
listAllPacks.clear()
getPackById.clear()
inferPackFromNodeName.clear()
}
/**
@@ -120,6 +132,7 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
getNodeDefs.cancel()
listAllPacks.cancel()
getPackById.cancel()
inferPackFromNodeName.cancel()
getPacksByIdController?.abort()
}
@@ -132,6 +145,7 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
},
getNodeDefs,
search,
inferPackFromNodeName,
clearCache,
cancelRequests,

View File

@@ -32,6 +32,9 @@ export const useReleaseStore = defineStore('release', () => {
const releaseTimestamp = computed(() =>
settingStore.get('Comfy.Release.Timestamp')
)
const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
// Most recent release
const recentRelease = computed(() => {
@@ -73,6 +76,11 @@ export const useReleaseStore = defineStore('release', () => {
// Show toast if needed
const shouldShowToast = computed(() => {
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isNewVersionAvailable.value) {
return false
}
@@ -85,7 +93,7 @@ export const useReleaseStore = defineStore('release', () => {
// Skip if user already skipped or changelog seen
if (
releaseVersion.value === recentRelease.value?.version &&
!['skipped', 'changelog seen'].includes(releaseStatus.value)
['skipped', 'changelog seen'].includes(releaseStatus.value)
) {
return false
}
@@ -95,6 +103,11 @@ export const useReleaseStore = defineStore('release', () => {
// Show red-dot indicator
const shouldShowRedDot = computed(() => {
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
// Already latest → no dot
if (!isNewVersionAvailable.value) {
return false
@@ -132,6 +145,11 @@ export const useReleaseStore = defineStore('release', () => {
// Show "What's New" popup
const shouldShowPopup = computed(() => {
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isLatestVersion.value) {
return false
}
@@ -183,7 +201,14 @@ export const useReleaseStore = defineStore('release', () => {
// Fetch releases from API
async function fetchReleases(): Promise<void> {
if (isLoading.value) return
if (isLoading.value) {
return
}
// Skip fetching if notifications are disabled
if (!showVersionUpdates.value) {
return
}
isLoading.value = true
error.value = null

View File

@@ -7,6 +7,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { SettingParams } from '@/types/settingTypes'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
export const getSettingInfo = (setting: SettingParams) => {
const parts = setting.category || setting.id.split('.')
@@ -20,16 +21,24 @@ export interface SettingTreeNode extends TreeNode {
data?: SettingParams
}
function tryMigrateDeprecatedValue(setting: SettingParams, value: any) {
function tryMigrateDeprecatedValue(
setting: SettingParams | undefined,
value: unknown
) {
return setting?.migrateDeprecatedValue?.(value) ?? value
}
function onChange(setting: SettingParams, newValue: any, oldValue: any) {
function onChange(
setting: SettingParams | undefined,
newValue: unknown,
oldValue: unknown
) {
if (setting?.onChange) {
setting.onChange(newValue, oldValue)
}
// Backward compatibility with old settings dialog.
// Some extensions still listens event emitted by the old settings dialog.
// @ts-expect-error 'setting' is possibly 'undefined'.ts(18048)
app.ui.settings.dispatchChange(setting.id, newValue, oldValue)
}
@@ -76,16 +85,73 @@ export const useSettingStore = defineStore('setting', () => {
return _.cloneDeep(settingValues.value[key] ?? getDefaultValue(key))
}
/**
* Gets the setting params, asserting the type that is intentionally left off
* of {@link settingsById}.
* @param key The key of the setting to get.
* @returns The setting.
*/
function getSettingById<K extends keyof Settings>(
key: K
): SettingParams<Settings[K]> | undefined {
return settingsById.value[key] as SettingParams<Settings[K]> | undefined
}
/**
* Get the default value of a setting.
* @param key - The key of the setting to get.
* @returns The default value of the setting.
*/
function getDefaultValue<K extends keyof Settings>(key: K): Settings[K] {
const param = settingsById.value[key]
return typeof param?.defaultValue === 'function'
function getDefaultValue<K extends keyof Settings>(
key: K
): Settings[K] | undefined {
// Assertion: settingsById is not typed.
const param = getSettingById(key)
if (param === undefined) return
const versionedDefault = getVersionedDefaultValue(key, param)
if (versionedDefault) {
return versionedDefault
}
return typeof param.defaultValue === 'function'
? param.defaultValue()
: param?.defaultValue
: param.defaultValue
}
function getVersionedDefaultValue<
K extends keyof Settings,
TValue = Settings[K]
>(key: K, param: SettingParams<TValue> | undefined): TValue | null {
// get default versioned value, skipping if the key is 'Comfy.InstalledVersion' to prevent infinite loop
const defaultsByInstallVersion = param?.defaultsByInstallVersion
if (defaultsByInstallVersion && key !== 'Comfy.InstalledVersion') {
const installedVersion = get('Comfy.InstalledVersion')
if (installedVersion) {
const sortedVersions = Object.keys(defaultsByInstallVersion).sort(
(a, b) => compareVersions(b, a)
)
for (const version of sortedVersions) {
// Ensure the version is in a valid format before comparing
if (!isSemVer(version)) {
continue
}
if (compareVersions(installedVersion, version) >= 0) {
const versionedDefault = defaultsByInstallVersion[version]
return typeof versionedDefault === 'function'
? versionedDefault()
: versionedDefault
}
}
}
}
return null
}
/**

View File

@@ -5,6 +5,7 @@ import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLib
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
import { useWorkflowsSidebarTab } from '@/composables/sidebarTabs/useWorkflowsSidebarTab'
import { t, te } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
import { SidebarTabExtension } from '@/types/extensionTypes'
@@ -25,11 +26,23 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
const registerSidebarTab = (tab: SidebarTabExtension) => {
sidebarTabs.value = [...sidebarTabs.value, tab]
// Generate label in format "Toggle X Sidebar"
const labelFunction = () => {
const tabTitle = te(tab.title) ? t(tab.title) : tab.title
return `Toggle ${tabTitle} Sidebar`
}
const tooltipFunction = tab.tooltip
? te(String(tab.tooltip))
? () => t(String(tab.tooltip))
: String(tab.tooltip)
: undefined
useCommandStore().registerCommand({
id: `Workspace.ToggleSidebarTab.${tab.id}`,
icon: tab.icon,
label: `Toggle ${tab.title} Sidebar`,
tooltip: tab.tooltip,
label: labelFunction,
tooltip: tooltipFunction,
versionAdded: '1.3.9',
function: () => {
toggleSidebarTab(tab.id)

View File

@@ -933,6 +933,26 @@ export interface paths {
patch?: never
trace?: never
}
'/comfy-nodes/{comfyNodeName}/node': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/**
* Retrieve a node by ComfyUI node name
* @description Returns the node that contains a ComfyUI node with the specified name
*/
get: operations['getNodeByComfyNodeName']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/nodes/{nodeId}': {
parameters: {
query?: never
@@ -2922,7 +2942,7 @@ export interface paths {
cookie?: never
}
/** Get Prompt Details */
get: operations['Moonvalley']
get: operations['MoonvalleyGetPrompt']
put?: never
post?: never
delete?: never
@@ -2931,7 +2951,7 @@ export interface paths {
patch?: never
trace?: never
}
'/proxy/moonvalley/prompts': {
'/proxy/moonvalley/text-to-video': {
parameters: {
query?: never
header?: never
@@ -2940,8 +2960,42 @@ export interface paths {
}
get?: never
put?: never
/** Create Text-to-Video or Image-to-Video Prompt */
post: operations['MoonvalleyCreatePrompt']
/** Create Text to Video Prompt */
post: operations['MoonvalleyTextToVideo']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/proxy/moonvalley/text-to-image': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Create Text to Image Prompt */
post: operations['MoonvalleyTextToImage']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/proxy/moonvalley/prompts/image-to-video': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Create Image to Video Prompt */
post: operations['MoonvalleyImageToVideo']
delete?: never
options?: never
head?: never
@@ -2957,15 +3011,15 @@ export interface paths {
}
get?: never
put?: never
/** Create Video-to-Video Prompt */
post: operations['MoonvalleyCreateVideoToVideoPrompt']
/** Create Video to Video Prompt */
post: operations['MoonvalleyVideoToVideo']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/proxy/moonvalley/prompts/text-to-image': {
'/proxy/moonvalley/prompts/video-to-video/resize': {
parameters: {
query?: never
header?: never
@@ -2974,8 +3028,8 @@ export interface paths {
}
get?: never
put?: never
/** Create Text-to-Image Prompt */
post: operations['MoonvalleyCreateTextToImagePrompt']
/** Resize a video */
post: operations['MoonvalleyVideoToVideoResize']
delete?: never
options?: never
head?: never
@@ -2991,8 +3045,8 @@ export interface paths {
}
get?: never
put?: never
/** Upload File */
post: operations['MoonvalleyUploadFile']
/** Upload Files */
post: operations['MoonvalleyUpload']
delete?: never
options?: never
head?: never
@@ -8709,6 +8763,22 @@ export interface components {
/** @default 0 */
conditioning_frame_index: number
}
MoonvalleyTextToImageRequest: {
prompt_text?: string
image_url?: string
inference_params?: components['schemas']['MoonvalleyInferenceParams']
webhook_url?: string
}
MoonvalleyTextToVideoRequest: {
prompt_text?: string
image_url?: string
inference_params?: components['schemas']['MoonvalleyInferenceParams']
webhook_url?: string
}
MoonvalleyVideoToVideoRequest: components['schemas']['MoonvalleyTextToVideoRequest'] & {
video_url: string
control_type: string
}
MoonvalleyPromptResponse: {
id?: string
status?: string
@@ -8720,26 +8790,23 @@ export interface components {
frame_conditioning?: Record<string, never>
error?: Record<string, never>
}
MoonvalleyCreatePromptRequest: {
prompt_text: string
image_url?: string
inference_params?: components['schemas']['MoonvalleyInferenceParams']
webhook_url?: string
MoonvalleyImageToVideoRequest: components['schemas']['MoonvalleyTextToVideoRequest'] & {
keyframes?: {
[key: string]: {
image_url?: string
}
}
}
MoonvalleyCreatePromptResponse: {
id?: string
status?: string
approximate_wait_time?: number
MoonvalleyResizeVideoRequest: components['schemas']['MoonvalleyVideoToVideoRequest'] & {
frame_position?: number[]
frame_resolution?: number[]
scale?: number[]
}
MoonvalleyCreateVideoToVideoRequest: {
prompt_text: string
video_url: string
/** @enum {string} */
control_type: 'motion_control'
inference_params?: components['schemas']['MoonvalleyInferenceParams']
webhook_url?: string
MoonvalleyUploadFileRequest: {
/** Format: binary */
file?: string
}
MoonvalleyUploadResponse: {
MoonvalleyUploadFileResponse: {
access_url?: string
}
/** @description GitHub release webhook payload based on official webhook documentation */
@@ -11287,6 +11354,47 @@ export interface operations {
}
}
}
getNodeByComfyNodeName: {
parameters: {
query?: never
header?: never
path: {
/** @description The name of the ComfyUI node */
comfyNodeName: string
}
cookie?: never
}
requestBody?: never
responses: {
/** @description Node details */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Node']
}
}
/** @description No node found containing the specified ComfyUI node name */
404: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
getNode: {
parameters: {
query?: {
@@ -11742,6 +11850,14 @@ export interface operations {
status?: components['schemas']['NodeVersionStatus']
/** @description The reason for the status change. */
status_reason?: string
/** @description Supported versions of ComfyUI frontend */
supported_comfyui_frontend_version?: string
/** @description Supported versions of ComfyUI */
supported_comfyui_version?: string
/** @description List of operating systems that this node supports */
supported_os?: string[]
/** @description List of accelerators (e.g. CUDA, DirectML, ROCm) that this node supports */
supported_accelerators?: string[]
}
}
}
@@ -18760,7 +18876,7 @@ export interface operations {
}
}
}
Moonvalley: {
MoonvalleyGetPrompt: {
parameters: {
query?: never
header?: never
@@ -18771,7 +18887,7 @@ export interface operations {
}
requestBody?: never
responses: {
/** @description Prompt details */
/** @description Prompt details retrieved */
200: {
headers: {
[name: string]: unknown
@@ -18782,7 +18898,7 @@ export interface operations {
}
}
}
MoonvalleyCreatePrompt: {
MoonvalleyTextToVideo: {
parameters: {
query?: never
header?: never
@@ -18791,7 +18907,7 @@ export interface operations {
}
requestBody: {
content: {
'application/json': components['schemas']['MoonvalleyCreatePromptRequest']
'application/json': components['schemas']['MoonvalleyTextToVideoRequest']
}
}
responses: {
@@ -18801,12 +18917,12 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['MoonvalleyCreatePromptResponse']
'application/json': components['schemas']['MoonvalleyPromptResponse']
}
}
}
}
MoonvalleyCreateVideoToVideoPrompt: {
MoonvalleyTextToImage: {
parameters: {
query?: never
header?: never
@@ -18815,7 +18931,7 @@ export interface operations {
}
requestBody: {
content: {
'application/json': components['schemas']['MoonvalleyCreateVideoToVideoRequest']
'application/json': components['schemas']['MoonvalleyTextToImageRequest']
}
}
responses: {
@@ -18825,12 +18941,12 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['MoonvalleyCreatePromptResponse']
'application/json': components['schemas']['MoonvalleyPromptResponse']
}
}
}
}
MoonvalleyCreateTextToImagePrompt: {
MoonvalleyImageToVideo: {
parameters: {
query?: never
header?: never
@@ -18839,7 +18955,7 @@ export interface operations {
}
requestBody: {
content: {
'application/json': components['schemas']['MoonvalleyCreatePromptRequest']
'application/json': components['schemas']['MoonvalleyImageToVideoRequest']
}
}
responses: {
@@ -18849,12 +18965,12 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['MoonvalleyCreatePromptResponse']
'application/json': components['schemas']['MoonvalleyPromptResponse']
}
}
}
}
MoonvalleyUploadFile: {
MoonvalleyVideoToVideo: {
parameters: {
query?: never
header?: never
@@ -18863,20 +18979,65 @@ export interface operations {
}
requestBody: {
content: {
'multipart/form-data': {
/** Format: binary */
file?: string
}
'application/json': components['schemas']['MoonvalleyVideoToVideoRequest']
}
}
responses: {
/** @description Upload successful */
/** @description Prompt created */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['MoonvalleyPromptResponse']
}
}
}
}
MoonvalleyVideoToVideoResize: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['MoonvalleyResizeVideoRequest']
}
}
responses: {
/** @description Prompt created */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['MoonvalleyPromptResponse']
}
}
}
}
MoonvalleyUpload: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'multipart/form-data': components['schemas']['MoonvalleyUploadFileRequest']
}
}
responses: {
/** @description File uploaded successfully */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['MoonvalleyUploadResponse']
'application/json': components['schemas']['MoonvalleyUploadFileResponse']
}
}
}

View File

@@ -32,9 +32,10 @@ export interface Setting {
render: () => HTMLElement
}
export interface SettingParams extends FormItem {
export interface SettingParams<TValue = unknown> extends FormItem {
id: keyof Settings
defaultValue: any | (() => any)
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
onChange?: (newValue: any, oldValue?: any) => void
// By default category is id.split('.'). However, changing id to assign
// new category has poor backward compatibility. Use this field to overwrite

View File

@@ -386,8 +386,10 @@ export const downloadUrlToHfRepoUrl = (url: string): string => {
}
}
export const isSemVer = (version: string) => {
const regex = /^(\d+)\.(\d+)\.(\d+)$/
export const isSemVer = (
version: string
): version is `${number}.${number}.${number}` => {
const regex = /^\d+\.\d+\.\d+$/
return regex.test(version)
}

View File

@@ -1,10 +1,10 @@
<template>
<div class="comfyui-body grid h-full w-full overflow-hidden">
<div id="comfyui-body-top" class="comfyui-body-top">
<TopMenubar v-if="useNewMenu === 'Top'" />
<TopMenubar v-if="showTopMenu" />
</div>
<div id="comfyui-body-bottom" class="comfyui-body-bottom">
<TopMenubar v-if="useNewMenu === 'Bottom'" />
<TopMenubar v-if="showBottomMenu" />
</div>
<div id="comfyui-body-left" class="comfyui-body-left" />
<div id="comfyui-body-right" class="comfyui-body-right" />
@@ -20,7 +20,7 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useBreakpoints, useEventListener } from '@vueuse/core'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onBeforeUnmount, onMounted, watch, watchEffect } from 'vue'
@@ -70,6 +70,12 @@ const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const colorPaletteStore = useColorPaletteStore()
const queueStore = useQueueStore()
const breakpoints = useBreakpoints({ md: 961 })
const isMobile = breakpoints.smaller('md')
const showTopMenu = computed(() => isMobile.value || useNewMenu.value === 'Top')
const showBottomMenu = computed(
() => !isMobile.value && useNewMenu.value === 'Bottom'
)
watch(
() => colorPaletteStore.completedActivePalette,

View File

@@ -227,7 +227,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.02/Run')
expect(price).toBe('$0.020/Run')
})
it('should return $0.018 for 512x512 size', () => {
@@ -255,7 +255,7 @@ describe('useNodePricing', () => {
const node = createMockNode('OpenAIDalle2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.016-0.02/Run (varies with size)')
expect(price).toBe('$0.016-0.02 x n/Run (varies with size & n)')
})
})
@@ -295,19 +295,19 @@ describe('useNodePricing', () => {
const node = createMockNode('OpenAIGPTImage1', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.011-0.30/Run (varies with quality)')
expect(price).toBe('$0.011-0.30 x n/Run (varies with quality & n)')
})
})
describe('dynamic pricing - IdeogramV3', () => {
it('should return $0.08 for Quality rendering speed', () => {
it('should return $0.09 for Quality rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Quality' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
expect(price).toBe('$0.09/Run')
})
it('should return $0.06 for Balanced rendering speed', () => {
@@ -335,7 +335,31 @@ describe('useNodePricing', () => {
const node = createMockNode('IdeogramV3', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03-0.08/Run (varies with rendering speed)')
expect(price).toBe(
'$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
)
})
it('should multiply price by num_images for Quality rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Quality' },
{ name: 'num_images', value: 3 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.27/Run') // 0.09 * 3
})
it('should multiply price by num_images for Turbo rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Turbo' },
{ name: 'num_images', value: 5 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.15/Run') // 0.03 * 5
})
})
@@ -742,6 +766,29 @@ describe('useNodePricing', () => {
expect(widgetNames).toEqual([])
})
describe('Ideogram nodes with num_images parameter', () => {
it('should return correct widget names for IdeogramV1', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV1')
expect(widgetNames).toEqual(['num_images'])
})
it('should return correct widget names for IdeogramV2', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV2')
expect(widgetNames).toEqual(['num_images'])
})
it('should return correct widget names for IdeogramV3', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV3')
expect(widgetNames).toEqual(['rendering_speed', 'num_images'])
})
})
describe('Recraft nodes with n parameter', () => {
it('should return correct widget names for RecraftTextToImageNode', () => {
const { getRelevantWidgetNames } = useNodePricing()
@@ -759,6 +806,54 @@ describe('useNodePricing', () => {
})
})
describe('Ideogram nodes dynamic pricing', () => {
it('should calculate dynamic pricing for IdeogramV1 based on num_images value', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV1', [
{ name: 'num_images', value: 3 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.18/Run') // 0.06 * 3
})
it('should calculate dynamic pricing for IdeogramV2 based on num_images value', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV2', [
{ name: 'num_images', value: 4 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.32/Run') // 0.08 * 4
})
it('should fall back to static display when num_images widget is missing for IdeogramV1', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV1', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06 x num_images/Run')
})
it('should fall back to static display when num_images widget is missing for IdeogramV2', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08 x num_images/Run')
})
it('should handle edge case when num_images value is 1 for IdeogramV1', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV1', [
{ name: 'num_images', value: 1 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06/Run') // 0.06 * 1
})
})
describe('Recraft nodes dynamic pricing', () => {
it('should calculate dynamic pricing for RecraftTextToImageNode based on n value', () => {
const { getNodeDisplayPrice } = useNodePricing()
@@ -799,4 +894,133 @@ describe('useNodePricing', () => {
})
})
})
describe('OpenAI nodes dynamic pricing with n parameter', () => {
it('should calculate dynamic pricing for OpenAIDalle2 based on size and n', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle2', [
{ name: 'size', value: '1024x1024' },
{ name: 'n', value: 3 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.060/Run') // 0.02 * 3
})
it('should calculate dynamic pricing for OpenAIGPTImage1 based on quality and n', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIGPTImage1', [
{ name: 'quality', value: 'low' },
{ name: 'n', value: 2 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.011-0.02 x 2/Run')
})
it('should fall back to static display when n widget is missing for OpenAIDalle2', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle2', [
{ name: 'size', value: '512x512' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.018/Run') // n defaults to 1
})
})
describe('KlingImageGenerationNode dynamic pricing with n parameter', () => {
it('should calculate dynamic pricing for text-to-image with kling-v1', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingImageGenerationNode', [
{ name: 'model_name', value: 'kling-v1' },
{ name: 'n', value: 4 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.0140/Run') // 0.0035 * 4
})
it('should calculate dynamic pricing for text-to-image with kling-v1-5', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Mock node without image input (text-to-image mode)
const node = createMockNode('KlingImageGenerationNode', [
{ name: 'model_name', value: 'kling-v1-5' },
{ name: 'n', value: 2 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.0280/Run') // For kling-v1-5 text-to-image: 0.014 * 2
})
it('should fall back to static display when model widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingImageGenerationNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.0035-0.028 x n/Run (varies with modality & model)')
})
})
describe('New Recraft nodes dynamic pricing', () => {
it('should calculate dynamic pricing for RecraftGenerateImageNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RecraftGenerateImageNode', [
{ name: 'n', value: 3 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.12/Run') // 0.04 * 3
})
it('should calculate dynamic pricing for RecraftVectorizeImageNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RecraftVectorizeImageNode', [
{ name: 'n', value: 5 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05/Run') // 0.01 * 5
})
it('should calculate dynamic pricing for RecraftGenerateVectorImageNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RecraftGenerateVectorImageNode', [
{ name: 'n', value: 2 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.16/Run') // 0.08 * 2
})
})
describe('Widget names for reactive updates', () => {
it('should include n parameter for OpenAI nodes', () => {
const { getRelevantWidgetNames } = useNodePricing()
expect(getRelevantWidgetNames('OpenAIDalle2')).toEqual(['size', 'n'])
expect(getRelevantWidgetNames('OpenAIGPTImage1')).toEqual([
'quality',
'n'
])
})
it('should include n parameter for Kling and new Recraft nodes', () => {
const { getRelevantWidgetNames } = useNodePricing()
expect(getRelevantWidgetNames('KlingImageGenerationNode')).toEqual([
'modality',
'model_name',
'n'
])
expect(getRelevantWidgetNames('RecraftVectorizeImageNode')).toEqual(['n'])
expect(getRelevantWidgetNames('RecraftGenerateImageNode')).toEqual(['n'])
expect(getRelevantWidgetNames('RecraftGenerateVectorImageNode')).toEqual([
'n'
])
expect(
getRelevantWidgetNames('RecraftGenerateColorFromImageNode')
).toEqual(['n'])
})
})
})

View File

@@ -0,0 +1,425 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useSettingSearch } from '@/composables/setting/useSettingSearch'
import { st } from '@/i18n'
import { getSettingInfo, useSettingStore } from '@/stores/settingStore'
// Mock dependencies
vi.mock('@/i18n', () => ({
st: vi.fn((_: string, fallback: string) => fallback)
}))
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(),
getSettingInfo: vi.fn()
}))
describe('useSettingSearch', () => {
let mockSettingStore: any
let mockSettings: any
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
// Mock settings data
mockSettings = {
'Category.Setting1': {
id: 'Category.Setting1',
name: 'Setting One',
type: 'text',
defaultValue: 'default',
category: ['Category', 'Basic']
},
'Category.Setting2': {
id: 'Category.Setting2',
name: 'Setting Two',
type: 'boolean',
defaultValue: false,
category: ['Category', 'Advanced']
},
'Category.HiddenSetting': {
id: 'Category.HiddenSetting',
name: 'Hidden Setting',
type: 'hidden',
defaultValue: 'hidden',
category: ['Category', 'Basic']
},
'Category.DeprecatedSetting': {
id: 'Category.DeprecatedSetting',
name: 'Deprecated Setting',
type: 'text',
defaultValue: 'deprecated',
deprecated: true,
category: ['Category', 'Advanced']
},
'Other.Setting3': {
id: 'Other.Setting3',
name: 'Other Setting',
type: 'select',
defaultValue: 'option1',
category: ['Other', 'SubCategory']
}
}
// Mock setting store
mockSettingStore = {
settingsById: mockSettings
}
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
// Mock getSettingInfo function
vi.mocked(getSettingInfo).mockImplementation((setting: any) => {
const parts = setting.category || setting.id.split('.')
return {
category: parts[0] ?? 'Other',
subCategory: parts[1] ?? 'Other'
}
})
// Mock st function to return fallback value
vi.mocked(st).mockImplementation((_: string, fallback: string) => fallback)
})
describe('initialization', () => {
it('initializes with default state', () => {
const search = useSettingSearch()
expect(search.searchQuery.value).toBe('')
expect(search.filteredSettingIds.value).toEqual([])
expect(search.searchInProgress.value).toBe(false)
expect(search.queryIsEmpty.value).toBe(true)
expect(search.inSearch.value).toBe(false)
expect(search.searchResultsCategories.value).toEqual(new Set())
})
})
describe('reactive properties', () => {
it('queryIsEmpty computed property works correctly', () => {
const search = useSettingSearch()
expect(search.queryIsEmpty.value).toBe(true)
search.searchQuery.value = 'test'
expect(search.queryIsEmpty.value).toBe(false)
search.searchQuery.value = ''
expect(search.queryIsEmpty.value).toBe(true)
})
it('inSearch computed property works correctly', () => {
const search = useSettingSearch()
// Empty query, not in search
expect(search.inSearch.value).toBe(false)
// Has query but search in progress
search.searchQuery.value = 'test'
search.searchInProgress.value = true
expect(search.inSearch.value).toBe(false)
// Has query and search complete
search.searchInProgress.value = false
expect(search.inSearch.value).toBe(true)
})
it('searchResultsCategories computed property works correctly', () => {
const search = useSettingSearch()
// No results
expect(search.searchResultsCategories.value).toEqual(new Set())
// Add some filtered results
search.filteredSettingIds.value = ['Category.Setting1', 'Other.Setting3']
expect(search.searchResultsCategories.value).toEqual(
new Set(['Category', 'Other'])
)
})
it('watches searchQuery and sets searchInProgress to true', async () => {
const search = useSettingSearch()
expect(search.searchInProgress.value).toBe(false)
search.searchQuery.value = 'test'
await nextTick()
expect(search.searchInProgress.value).toBe(true)
})
})
describe('handleSearch', () => {
it('clears results when query is empty', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = ['Category.Setting1']
search.handleSearch('')
expect(search.filteredSettingIds.value).toEqual([])
})
it('filters settings by ID (case insensitive)', () => {
const search = useSettingSearch()
search.handleSearch('category.setting1')
expect(search.filteredSettingIds.value).toContain('Category.Setting1')
expect(search.filteredSettingIds.value).not.toContain('Other.Setting3')
})
it('filters settings by name (case insensitive)', () => {
const search = useSettingSearch()
search.handleSearch('setting one')
expect(search.filteredSettingIds.value).toContain('Category.Setting1')
expect(search.filteredSettingIds.value).not.toContain('Category.Setting2')
})
it('filters settings by category', () => {
const search = useSettingSearch()
search.handleSearch('other')
expect(search.filteredSettingIds.value).toContain('Other.Setting3')
expect(search.filteredSettingIds.value).not.toContain('Category.Setting1')
})
it('excludes hidden settings from results', () => {
const search = useSettingSearch()
search.handleSearch('hidden')
expect(search.filteredSettingIds.value).not.toContain(
'Category.HiddenSetting'
)
})
it('excludes deprecated settings from results', () => {
const search = useSettingSearch()
search.handleSearch('deprecated')
expect(search.filteredSettingIds.value).not.toContain(
'Category.DeprecatedSetting'
)
})
it('sets searchInProgress to false after search', () => {
const search = useSettingSearch()
search.searchInProgress.value = true
search.handleSearch('test')
expect(search.searchInProgress.value).toBe(false)
})
it('includes visible settings in results', () => {
const search = useSettingSearch()
search.handleSearch('setting')
expect(search.filteredSettingIds.value).toEqual(
expect.arrayContaining([
'Category.Setting1',
'Category.Setting2',
'Other.Setting3'
])
)
expect(search.filteredSettingIds.value).not.toContain(
'Category.HiddenSetting'
)
expect(search.filteredSettingIds.value).not.toContain(
'Category.DeprecatedSetting'
)
})
it('includes all visible settings in comprehensive search', () => {
const search = useSettingSearch()
// Search for a partial match that should include multiple settings
search.handleSearch('setting')
// Should find all visible settings (not hidden/deprecated)
expect(search.filteredSettingIds.value.length).toBeGreaterThan(0)
expect(search.filteredSettingIds.value).toEqual(
expect.arrayContaining([
'Category.Setting1',
'Category.Setting2',
'Other.Setting3'
])
)
})
it('uses translated categories for search', () => {
const search = useSettingSearch()
// Mock st to return translated category names
vi.mocked(st).mockImplementation((key: string, fallback: string) => {
if (key === 'settingsCategories.Category') {
return 'Translated Category'
}
return fallback
})
search.handleSearch('translated category')
expect(search.filteredSettingIds.value).toEqual(
expect.arrayContaining(['Category.Setting1', 'Category.Setting2'])
)
})
})
describe('getSearchResults', () => {
it('groups results by subcategory', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = [
'Category.Setting1',
'Category.Setting2'
]
const results = search.getSearchResults(null)
expect(results).toEqual([
{
label: 'Basic',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'Advanced',
settings: [mockSettings['Category.Setting2']]
}
])
})
it('filters results by active category', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = ['Category.Setting1', 'Other.Setting3']
const activeCategory = { label: 'Category' } as any
const results = search.getSearchResults(activeCategory)
expect(results).toEqual([
{
label: 'Basic',
settings: [mockSettings['Category.Setting1']]
}
])
})
it('returns all results when no active category', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = ['Category.Setting1', 'Other.Setting3']
const results = search.getSearchResults(null)
expect(results).toEqual([
{
label: 'Basic',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'SubCategory',
settings: [mockSettings['Other.Setting3']]
}
])
})
it('returns empty array when no filtered results', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = []
const results = search.getSearchResults(null)
expect(results).toEqual([])
})
it('handles multiple settings in same subcategory', () => {
const search = useSettingSearch()
// Add another setting to Basic subcategory
mockSettings['Category.Setting4'] = {
id: 'Category.Setting4',
name: 'Setting Four',
type: 'text',
defaultValue: 'default',
category: ['Category', 'Basic']
}
search.filteredSettingIds.value = [
'Category.Setting1',
'Category.Setting4'
]
const results = search.getSearchResults(null)
expect(results).toEqual([
{
label: 'Basic',
settings: [
mockSettings['Category.Setting1'],
mockSettings['Category.Setting4']
]
}
])
})
})
describe('edge cases', () => {
it('handles empty settings store', () => {
mockSettingStore.settingsById = {}
const search = useSettingSearch()
search.handleSearch('test')
expect(search.filteredSettingIds.value).toEqual([])
})
it('handles settings with undefined category', () => {
mockSettings['NoCategorySetting'] = {
id: 'NoCategorySetting',
name: 'No Category',
type: 'text',
defaultValue: 'default'
}
const search = useSettingSearch()
search.handleSearch('category')
expect(search.filteredSettingIds.value).toContain('NoCategorySetting')
})
it('handles special characters in search query', () => {
const search = useSettingSearch()
// Search for part of the ID that contains a dot
search.handleSearch('category.setting')
expect(search.filteredSettingIds.value).toContain('Category.Setting1')
})
it('handles very long search queries', () => {
const search = useSettingSearch()
const longQuery = 'a'.repeat(1000)
search.handleSearch(longQuery)
expect(search.filteredSettingIds.value).toEqual([])
})
it('handles rapid consecutive searches', async () => {
const search = useSettingSearch()
search.handleSearch('setting')
search.handleSearch('other')
search.handleSearch('category')
expect(search.filteredSettingIds.value).toEqual(
expect.arrayContaining(['Category.Setting1', 'Category.Setting2'])
)
})
})
})

View File

@@ -0,0 +1,442 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockLocalStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
}))
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true
})
vi.mock('@/config/version', () => ({
__COMFYUI_FRONTEND_VERSION__: '1.24.0'
}))
//@ts-expect-error Define global for the test
global.__COMFYUI_FRONTEND_VERSION__ = '1.24.0'
describe('newUserService', () => {
let service: ReturnType<
typeof import('@/services/newUserService').newUserService
>
let mockSettingStore: any
let newUserService: typeof import('@/services/newUserService').newUserService
beforeEach(async () => {
vi.clearAllMocks()
vi.resetModules()
const module = await import('@/services/newUserService')
newUserService = module.newUserService
service = newUserService()
mockSettingStore = {
settingValues: {},
get: vi.fn(),
set: vi.fn()
}
mockLocalStorage.getItem.mockReturnValue(null)
})
describe('checkIsNewUser logic', () => {
it('should identify new user when all conditions are met', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
it('should identify new user when settings exist but TutorialCompleted is undefined', async () => {
mockSettingStore.settingValues = { 'some.setting': 'value' }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
it('should identify existing user when tutorial is completed', async () => {
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': true }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return true
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
it('should identify existing user when workflow exists', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'workflow') return 'some-workflow'
return null
})
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
it('should identify existing user when previous workflow exists', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'Comfy.PreviousWorkflow') return 'some-previous-workflow'
return null
})
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
it('should identify new user when tutorial is explicitly false', async () => {
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return false
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
it('should identify existing user when has both settings and tutorial completed', async () => {
mockSettingStore.settingValues = {
'some.setting': 'value',
'Comfy.TutorialCompleted': true
}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return true
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
it('should identify existing user when only one condition fails', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'workflow') return 'some-workflow'
if (key === 'Comfy.PreviousWorkflow') return null
return null
})
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
})
describe('registerInitCallback', () => {
it('should execute callback immediately if new user is already determined', async () => {
const mockCallback = vi.fn().mockResolvedValue(undefined)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
await service.registerInitCallback(mockCallback)
expect(mockCallback).toHaveBeenCalledTimes(1)
})
it('should queue callbacks when user status is not determined', async () => {
const mockCallback = vi.fn().mockResolvedValue(undefined)
await service.registerInitCallback(mockCallback)
expect(mockCallback).not.toHaveBeenCalled()
expect(service.isNewUser()).toBeNull()
})
it('should handle callback errors gracefully', async () => {
const mockCallback = vi
.fn()
.mockRejectedValue(new Error('Callback error'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
await service.registerInitCallback(mockCallback)
expect(consoleSpy).toHaveBeenCalledWith(
'New user initialization callback failed:',
expect.any(Error)
)
consoleSpy.mockRestore()
})
})
describe('initializeIfNewUser', () => {
it('should set installed version for new users', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.InstalledVersion',
'1.24.0'
)
})
it('should not set installed version for existing users', async () => {
mockSettingStore.settingValues = { 'some.setting': 'value' }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return true
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('should execute pending callbacks for new users', async () => {
const mockCallback1 = vi.fn().mockResolvedValue(undefined)
const mockCallback2 = vi.fn().mockResolvedValue(undefined)
await service.registerInitCallback(mockCallback1)
await service.registerInitCallback(mockCallback2)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockCallback1).toHaveBeenCalledTimes(1)
expect(mockCallback2).toHaveBeenCalledTimes(1)
})
it('should not execute pending callbacks for existing users', async () => {
const mockCallback = vi.fn().mockResolvedValue(undefined)
await service.registerInitCallback(mockCallback)
mockSettingStore.settingValues = { 'some.setting': 'value' }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return true
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockCallback).not.toHaveBeenCalled()
})
it('should handle callback errors during initialization', async () => {
const mockCallback = vi.fn().mockRejectedValue(new Error('Init error'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await service.registerInitCallback(mockCallback)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(consoleSpy).toHaveBeenCalledWith(
'New user initialization callback failed:',
expect.any(Error)
)
consoleSpy.mockRestore()
})
it('should not reinitialize if already determined', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockSettingStore.set).toHaveBeenCalledTimes(1)
await service.initializeIfNewUser(mockSettingStore)
expect(mockSettingStore.set).toHaveBeenCalledTimes(1)
})
it('should correctly determine new user status', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
// Before initialization, isNewUser should return null
expect(service.isNewUser()).toBeNull()
await service.initializeIfNewUser(mockSettingStore)
// After initialization, isNewUser should return true for a new user
expect(service.isNewUser()).toBe(true)
// Should set the installed version for new users
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.InstalledVersion',
expect.any(String)
)
})
})
describe('isNewUser', () => {
it('should return null before determination', () => {
expect(service.isNewUser()).toBeNull()
})
it('should return cached result after determination', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockReturnValue(undefined)
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
})
describe('edge cases', () => {
it('should handle settingStore.get returning false as not completed', async () => {
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return false
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
it('should handle multiple callback registrations after initialization', async () => {
const mockCallback1 = vi.fn().mockResolvedValue(undefined)
const mockCallback2 = vi.fn().mockResolvedValue(undefined)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
await service.registerInitCallback(mockCallback1)
await service.registerInitCallback(mockCallback2)
expect(mockCallback1).toHaveBeenCalledTimes(1)
expect(mockCallback2).toHaveBeenCalledTimes(1)
})
})
describe('state sharing between instances', () => {
it('should share state between multiple service instances', async () => {
const service1 = newUserService()
const service2 = newUserService()
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service1.initializeIfNewUser(mockSettingStore)
expect(service2.isNewUser()).toBe(true)
expect(service1.isNewUser()).toBe(service2.isNewUser())
})
it('should execute callbacks registered on different instances', async () => {
const service1 = newUserService()
const service2 = newUserService()
const mockCallback1 = vi.fn().mockResolvedValue(undefined)
const mockCallback2 = vi.fn().mockResolvedValue(undefined)
await service1.registerInitCallback(mockCallback1)
await service2.registerInitCallback(mockCallback2)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service1.initializeIfNewUser(mockSettingStore)
expect(mockCallback1).toHaveBeenCalledTimes(1)
expect(mockCallback2).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -72,6 +72,14 @@ describe('useComfyRegistryStore', () => {
error: ReturnType<typeof ref<string | null>>
listAllPacks: ReturnType<typeof vi.fn>
getPackById: ReturnType<typeof vi.fn>
inferPackFromNodeName: ReturnType<typeof vi.fn>
search: ReturnType<typeof vi.fn>
getPackVersions: ReturnType<typeof vi.fn>
getPackByVersion: ReturnType<typeof vi.fn>
getPublisherById: ReturnType<typeof vi.fn>
listPacksForPublisher: ReturnType<typeof vi.fn>
getNodeDefs: ReturnType<typeof vi.fn>
postPackReview: ReturnType<typeof vi.fn>
}
beforeEach(() => {
@@ -106,7 +114,15 @@ describe('useComfyRegistryStore', () => {
// Otherwise return paginated results
return Promise.resolve(mockListResult)
}),
getPackById: vi.fn().mockResolvedValue(mockNodePack)
getPackById: vi.fn().mockResolvedValue(mockNodePack),
inferPackFromNodeName: vi.fn().mockResolvedValue(mockNodePack),
search: vi.fn().mockResolvedValue(mockListResult),
getPackVersions: vi.fn().mockResolvedValue([]),
getPackByVersion: vi.fn().mockResolvedValue({}),
getPublisherById: vi.fn().mockResolvedValue({}),
listPacksForPublisher: vi.fn().mockResolvedValue([]),
getNodeDefs: vi.fn().mockResolvedValue({}),
postPackReview: vi.fn().mockResolvedValue({})
}
vi.mocked(useComfyRegistryService).mockReturnValue(
@@ -186,4 +202,58 @@ describe('useComfyRegistryStore', () => {
expect.any(Object) // abort signal
)
})
describe('inferPackFromNodeName', () => {
it('should fetch a pack by comfy node name', async () => {
const store = useComfyRegistryStore()
const nodeName = 'KSampler'
const result = await store.inferPackFromNodeName.call(nodeName)
expect(result).toEqual(mockNodePack)
expect(mockRegistryService.inferPackFromNodeName).toHaveBeenCalledWith(
nodeName,
expect.any(Object) // abort signal
)
})
it('should cache results', async () => {
const store = useComfyRegistryStore()
const nodeName = 'KSampler'
// First call
const result1 = await store.inferPackFromNodeName.call(nodeName)
expect(mockRegistryService.inferPackFromNodeName).toHaveBeenCalledTimes(1)
// Second call - should use cache
const result2 = await store.inferPackFromNodeName.call(nodeName)
expect(mockRegistryService.inferPackFromNodeName).toHaveBeenCalledTimes(1)
expect(result2).toEqual(result1)
})
it('should handle null results when node is not found', async () => {
mockRegistryService.inferPackFromNodeName.mockResolvedValueOnce(null)
const store = useComfyRegistryStore()
const result = await store.inferPackFromNodeName.call('NonExistentNode')
expect(result).toBeNull()
})
it('should clear cache when clearCache is called', async () => {
const store = useComfyRegistryStore()
const nodeName = 'KSampler'
// First call to populate cache
await store.inferPackFromNodeName.call(nodeName)
expect(mockRegistryService.inferPackFromNodeName).toHaveBeenCalledTimes(1)
// Clear cache
store.clearCache()
// Call again - should hit the service again
await store.inferPackFromNodeName.call(nodeName)
expect(mockRegistryService.inferPackFromNodeName).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -61,6 +61,12 @@ describe('useReleaseStore', () => {
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
// Default showVersionUpdates to true
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
store = useReleaseStore()
})
@@ -114,6 +120,107 @@ describe('useReleaseStore', () => {
})
})
describe('showVersionUpdates setting', () => {
beforeEach(() => {
store.releases = [mockRelease]
})
describe('when notifications are enabled', () => {
beforeEach(() => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
it('should show toast for medium/high attention releases', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
// Need multiple releases for hasMediumOrHighAttention to work
const mediumRelease = {
...mockRelease,
id: 2,
attention: 'medium' as const
}
store.releases = [mockRelease, mediumRelease]
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
})
it('should fetch releases during initialization', async () => {
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows'
})
})
})
describe('when notifications are disabled', () => {
beforeEach(() => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
})
it('should not show toast even with new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
it('should not show red dot even with new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should not show popup even for latest version', async () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})
it('should skip fetching releases during initialization', async () => {
await store.initialize()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
})
it('should not fetch releases when calling fetchReleases directly', async () => {
await store.fetchReleases()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
})
})
describe('release initialization', () => {
it('should fetch releases successfully', async () => {
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
@@ -184,6 +291,17 @@ describe('useReleaseStore', () => {
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
})
it('should not set loading state when notifications disabled', async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
await store.initialize()
expect(store.isLoading).toBe(false)
})
})
describe('action handlers', () => {
@@ -248,6 +366,7 @@ describe('useReleaseStore', () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Release.Version') return null
if (key === 'Comfy.Release.Status') return null
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
@@ -267,7 +386,10 @@ describe('useReleaseStore', () => {
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
mockSettingStore.get.mockReturnValue(null)
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
store.releases = [mockRelease]
@@ -276,7 +398,10 @@ describe('useReleaseStore', () => {
it('should show popup for latest version', async () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0' // Same as release
mockSettingStore.get.mockReturnValue(null)
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0) // versions are equal (latest version)
@@ -286,4 +411,37 @@ describe('useReleaseStore', () => {
expect(store.shouldShowPopup).toBe(true)
})
})
describe('edge cases', () => {
it('should handle missing system stats gracefully', async () => {
mockSystemStatsStore.systemStats = null
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
await store.initialize()
// Should not fetch system stats when notifications disabled
expect(mockSystemStatsStore.fetchSystemStats).not.toHaveBeenCalled()
})
it('should handle concurrent fetchReleases calls', async () => {
mockReleaseService.getReleases.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve([mockRelease]), 100)
)
)
// Start two concurrent calls
const promise1 = store.fetchReleases()
const promise2 = store.fetchReleases()
await Promise.all([promise1, promise2])
// Should only call API once due to loading check
expect(mockReleaseService.getReleases).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -109,6 +109,241 @@ describe('useSettingStore', () => {
})
})
describe('getDefaultValue', () => {
beforeEach(() => {
// Set up installed version for most tests
store.settingValues['Comfy.InstalledVersion'] = '1.30.0'
})
it('should return regular default value when no defaultsByInstallVersion', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default'
}
store.addSetting(setting)
const result = store.getDefaultValue('test.setting')
expect(result).toBe('regular-default')
})
it('should return versioned default when user version matches', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default',
defaultsByInstallVersion: {
'1.21.3': 'version-1.21.3-default',
'1.40.3': 'version-1.40.3-default'
}
}
store.addSetting(setting)
const result = store.getDefaultValue('test.setting')
// installedVersion is 1.30.0, so should get 1.21.3 default
expect(result).toBe('version-1.21.3-default')
})
it('should return latest versioned default when user version is higher', () => {
store.settingValues['Comfy.InstalledVersion'] = '1.50.0'
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default',
defaultsByInstallVersion: {
'1.21.3': 'version-1.21.3-default',
'1.40.3': 'version-1.40.3-default'
}
}
store.addSetting(setting)
const result = store.getDefaultValue('test.setting')
// installedVersion is 1.50.0, so should get 1.40.3 default
expect(result).toBe('version-1.40.3-default')
})
it('should return regular default when user version is lower than all versioned defaults', () => {
store.settingValues['Comfy.InstalledVersion'] = '1.10.0'
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default',
defaultsByInstallVersion: {
'1.21.3': 'version-1.21.3-default',
'1.40.3': 'version-1.40.3-default'
}
}
store.addSetting(setting)
const result = store.getDefaultValue('test.setting')
// installedVersion is 1.10.0, lower than all versioned defaults
expect(result).toBe('regular-default')
})
it('should return regular default when no installed version (existing users)', () => {
// Clear installed version to simulate existing user
delete store.settingValues['Comfy.InstalledVersion']
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default',
defaultsByInstallVersion: {
'1.21.3': 'version-1.21.3-default',
'1.40.3': 'version-1.40.3-default'
}
}
store.addSetting(setting)
const result = store.getDefaultValue('test.setting')
// No installed version, should use backward compatibility
expect(result).toBe('regular-default')
})
it('should handle function-based versioned defaults', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default',
defaultsByInstallVersion: {
'1.21.3': () => 'dynamic-version-1.21.3-default',
'1.40.3': () => 'dynamic-version-1.40.3-default'
}
}
store.addSetting(setting)
const result = store.getDefaultValue('test.setting')
// installedVersion is 1.30.0, so should get 1.21.3 default (executed)
expect(result).toBe('dynamic-version-1.21.3-default')
})
it('should handle function-based regular defaults with versioned defaults', () => {
store.settingValues['Comfy.InstalledVersion'] = '1.10.0'
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: () => 'dynamic-regular-default',
defaultsByInstallVersion: {
'1.21.3': 'version-1.21.3-default',
'1.40.3': 'version-1.40.3-default'
}
}
store.addSetting(setting)
const result = store.getDefaultValue('test.setting')
// installedVersion is 1.10.0, should fallback to function-based regular default
expect(result).toBe('dynamic-regular-default')
})
it('should handle complex version comparison correctly', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default',
defaultsByInstallVersion: {
'1.21.3': 'version-1.21.3-default',
'1.21.10': 'version-1.21.10-default',
'1.40.3': 'version-1.40.3-default'
}
}
store.addSetting(setting)
// Test with 1.21.5 - should get 1.21.3 default
store.settingValues['Comfy.InstalledVersion'] = '1.21.5'
expect(store.getDefaultValue('test.setting')).toBe(
'version-1.21.3-default'
)
// Test with 1.21.15 - should get 1.21.10 default
store.settingValues['Comfy.InstalledVersion'] = '1.21.15'
expect(store.getDefaultValue('test.setting')).toBe(
'version-1.21.10-default'
)
// Test with 1.21.3 exactly - should get 1.21.3 default
store.settingValues['Comfy.InstalledVersion'] = '1.21.3'
expect(store.getDefaultValue('test.setting')).toBe(
'version-1.21.3-default'
)
})
it('should work with get() method using versioned defaults', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default',
defaultsByInstallVersion: {
'1.21.3': 'version-1.21.3-default',
'1.40.3': 'version-1.40.3-default'
}
}
store.addSetting(setting)
// get() should use getDefaultValue internally
const result = store.get('test.setting')
expect(result).toBe('version-1.21.3-default')
})
it('should handle mixed function and static versioned defaults', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default',
defaultsByInstallVersion: {
'1.21.3': () => 'dynamic-1.21.3-default',
'1.40.3': 'static-1.40.3-default'
}
}
store.addSetting(setting)
// Test with 1.30.0 - should get dynamic 1.21.3 default
store.settingValues['Comfy.InstalledVersion'] = '1.30.0'
expect(store.getDefaultValue('test.setting')).toBe(
'dynamic-1.21.3-default'
)
// Test with 1.50.0 - should get static 1.40.3 default
store.settingValues['Comfy.InstalledVersion'] = '1.50.0'
expect(store.getDefaultValue('test.setting')).toBe(
'static-1.40.3-default'
)
})
it('should handle version sorting correctly', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'Test Setting',
type: 'text',
defaultValue: 'regular-default',
defaultsByInstallVersion: {
'1.40.3': 'version-1.40.3-default',
'1.21.3': 'version-1.21.3-default', // Unsorted order
'1.35.0': 'version-1.35.0-default'
}
}
store.addSetting(setting)
// Test with 1.37.0 - should get 1.35.0 default (highest version <= 1.37.0)
store.settingValues['Comfy.InstalledVersion'] = '1.37.0'
expect(store.getDefaultValue('test.setting')).toBe(
'version-1.35.0-default'
)
})
})
describe('get and set', () => {
it('should get default value when setting not exists', () => {
const setting: SettingParams = {

View File

@@ -7,11 +7,7 @@ import { type UserConfig, defineConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import {
addElementVnodeExportPlugin,
comfyAPIPlugin,
generateImportMapPlugin
} from './build/plugins'
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
dotenv.config()
@@ -88,11 +84,40 @@ export default defineConfig({
: [vue()]),
comfyAPIPlugin(IS_DEV),
generateImportMapPlugin([
{ name: 'vue', pattern: /[\\/]node_modules[\\/]vue[\\/]/ },
{ name: 'primevue', pattern: /[\\/]node_modules[\\/]primevue[\\/]/ },
{ name: 'vue-i18n', pattern: /[\\/]node_modules[\\/]vue-i18n[\\/]/ }
{
name: 'vue',
pattern: 'vue',
entry: './dist/vue.esm-browser.prod.js'
},
{
name: 'vue-i18n',
pattern: 'vue-i18n',
entry: './dist/vue-i18n.esm-browser.prod.js'
},
{
name: 'primevue',
pattern: /^primevue\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/themes',
pattern: /^@primevue\/themes\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/forms',
pattern: /^@primevue\/forms\/?.*/,
entry: './index.mjs',
recursiveDependence: true,
override: {
'@primeuix/forms': {
entry: ''
}
}
}
]),
addElementVnodeExportPlugin(),
Icons({
compiler: 'vue3'