mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 00:20:07 +00:00
Merge remote-tracking branch 'upstream/main' into js/async_nodes
This commit is contained in:
654
.claude/commands/create-frontend-release.md
Normal file
654
.claude/commands/create-frontend-release.md
Normal 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
|
||||
|
||||
222
.claude/commands/create-hotfix-release.md
Normal file
222
.claude/commands/create-hotfix-release.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
`
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
289
browser_tests/tests/useSettingSearch.spec.ts
Normal file
289
browser_tests/tests/useSettingSearch.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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
192
scripts/check-unused-i18n-keys.ts
Executable 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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] || []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
172
src/locales/CONTRIBUTING.md
Normal 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.*
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 d’attente",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": "ファイル名を入力",
|
||||
|
||||
@@ -259,6 +259,10 @@
|
||||
"name": "スナップハイライトノード",
|
||||
"tooltip": "有効な入力スロットを持つノードの上にリンクをドラッグすると、ノードがハイライトされます"
|
||||
},
|
||||
"Comfy_Notification_ShowVersionUpdates": {
|
||||
"name": "バージョン更新を表示",
|
||||
"tooltip": "新しいモデルや主要な新機能のアップデートを表示します。"
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "ポインタークリックドリフト遅延",
|
||||
"tooltip": "ポインターボタンを押した後、ポインタの動きが無視される最大時間(ミリ秒単位)です。\n\nクリック中にポインタが移動した場合、オブジェクトが意図せず動かされるのを防ぎます。"
|
||||
|
||||
@@ -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": "워크플로 저장"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,10 @@
|
||||
"name": "스냅 하이라이트 노드",
|
||||
"tooltip": "링크를 유효한 입력 슬롯이 있는 노드 위로 드래그할 때 노드를 강조 표시합니다."
|
||||
},
|
||||
"Comfy_Notification_ShowVersionUpdates": {
|
||||
"name": "버전 업데이트 표시",
|
||||
"tooltip": "새 모델과 주요 신규 기능에 대한 업데이트를 표시합니다."
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "포인터 클릭 드리프트 지연",
|
||||
"tooltip": "포인터 버튼을 누른 후, 포인터 움직임을 무시할 수 있는 최대 시간(밀리초)입니다.\n\n클릭하는 동안 포인터가 움직여 의도치 않게 객체가 밀리는 것을 방지합니다."
|
||||
|
||||
@@ -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": "Сохранить рабочий процесс"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,10 @@
|
||||
"name": "Подсветка ноды при привязке",
|
||||
"tooltip": "При перетаскивании ссылки над нодой с подходящим входным слотом, нода подсвечивается"
|
||||
},
|
||||
"Comfy_Notification_ShowVersionUpdates": {
|
||||
"name": "Показывать обновления версий",
|
||||
"tooltip": "Показывать обновления новых моделей и основные новые функции."
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "Задержка дрейфа щелчка указателя",
|
||||
"tooltip": "После нажатия кнопки указателя, это максимальное время (в миллисекундах), в течение которого движение указателя может быть проигнорировано.\n\nПомогает предотвратить непреднамеренное смещение объектов, если указатель перемещается во время щелчка."
|
||||
|
||||
249
src/locales/zh-TW/commands.json
Normal file
249
src/locales/zh-TW/commands.json
Normal 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
1495
src/locales/zh-TW/main.json
Normal file
File diff suppressed because it is too large
Load Diff
8660
src/locales/zh-TW/nodeDefs.json
Normal file
8660
src/locales/zh-TW/nodeDefs.json
Normal file
File diff suppressed because it is too large
Load Diff
412
src/locales/zh-TW/settings.json
Normal file
412
src/locales/zh-TW/settings.json
Normal 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": "總是對齊格線"
|
||||
}
|
||||
}
|
||||
@@ -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": "输入文件名",
|
||||
|
||||
@@ -259,6 +259,10 @@
|
||||
"name": "吸附高亮节点",
|
||||
"tooltip": "在拖动连线经过具有可用输入接口的节点时,高亮显示该节点。"
|
||||
},
|
||||
"Comfy_Notification_ShowVersionUpdates": {
|
||||
"name": "显示版本更新",
|
||||
"tooltip": "显示新模型和主要新功能的更新。"
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "指针点击漂移延迟",
|
||||
"tooltip": "按下指针按钮后,忽略指针移动的最大时间(毫秒)。\n\n有助于防止在点击时意外移动鼠标。"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
74
src/services/newUserService.ts
Normal file
74
src/services/newUserService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
425
tests-ui/tests/composables/useSettingSearch.test.ts
Normal file
425
tests-ui/tests/composables/useSettingSearch.test.ts
Normal 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'])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
442
tests-ui/tests/services/newUserService.test.ts
Normal file
442
tests-ui/tests/services/newUserService.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user