mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-14 19:27:09 +00:00
Compare commits
23 Commits
bl-seconda
...
fix/api-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a8684bcef | ||
|
|
c03771988d | ||
|
|
368c54bcf6 | ||
|
|
f1575a693f | ||
|
|
e770b81df9 | ||
|
|
4eeff5533a | ||
|
|
c7877dbd18 | ||
|
|
4cbcded820 | ||
|
|
469594e5cc | ||
|
|
191b4574b9 | ||
|
|
0c4339f652 | ||
|
|
35556eb674 | ||
|
|
92b65ca00e | ||
|
|
8f4e807468 | ||
|
|
c1db367422 | ||
|
|
3b435e337e | ||
|
|
ee5088551e | ||
|
|
44bbfa9f39 | ||
|
|
1b4ad61e7f | ||
|
|
7befec5b17 | ||
|
|
a51c09893f | ||
|
|
f290c00a61 | ||
|
|
a45753486d |
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
|
||||
|
||||
58
CLAUDE.md
58
CLAUDE.md
@@ -0,0 +1,58 @@
|
||||
- use `npm run` to see what commands are available
|
||||
- For component communication, prefer Vue's event-based pattern (emit/@event-name) for state changes and notifications; use defineExpose with refs only for imperative operations that need direct control (like form.validate(), modal.open(), or editor.focus()); events promote loose coupling and are better for reusable components, while exposed methods are acceptable for tightly-coupled component pairs or when wrapping third-party libraries that require imperative APIs
|
||||
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
|
||||
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
|
||||
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
|
||||
- Never add lines to PR descriptions or commit messages that say "Generated with Claude Code"
|
||||
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
|
||||
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
|
||||
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
|
||||
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
|
||||
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
|
||||
- Prefer running single tests, and not the whole test suite, for performance
|
||||
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
|
||||
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
|
||||
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
|
||||
- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
|
||||
- The npm script to type check is called "typecheck" NOT "type check"
|
||||
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
|
||||
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
|
||||
- Never write css if you can accomplish the same thing with tailwind utility classes
|
||||
- Utilize ref and reactive for reactive state
|
||||
- Implement computed properties with computed()
|
||||
- Use watch and watchEffect for side effects
|
||||
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
||||
- Utilize provide/inject for dependency injection
|
||||
- Use vue 3.5 style of default prop declaration. Do not define a `props` variable; instead, destructure props. Since vue 3.5, destructuring props does not strip them of reactivity.
|
||||
- Use Tailwind CSS for styling
|
||||
- Leverage VueUse functions for performance-enhancing styles
|
||||
- Use lodash for utility functions
|
||||
- Implement proper props and emits definitions
|
||||
- Utilize Vue 3's Teleport component when needed
|
||||
- Use Suspense for async components
|
||||
- Implement proper error handling
|
||||
- Follow Vue 3 style guide and naming conventions
|
||||
- IMPORTANT: Use vue-i18n for ALL user-facing strings - no hard-coded text in services/utilities. Place new translation entries in src/locales/en/main.json
|
||||
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
|
||||
- DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
* `Dropdown` → Use `Select` (import from 'primevue/select')
|
||||
* `OverlayPanel` → Use `Popover` (import from 'primevue/popover')
|
||||
* `Calendar` → Use `DatePicker` (import from 'primevue/datepicker')
|
||||
* `InputSwitch` → Use `ToggleSwitch` (import from 'primevue/toggleswitch')
|
||||
* `Sidebar` → Use `Drawer` (import from 'primevue/drawer')
|
||||
* `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
|
||||
* `TabMenu` → Use `Tabs` without panels
|
||||
* `Steps` → Use `Stepper` without panels
|
||||
* `InlineMessage` → Use `Message` component
|
||||
* Use `api.apiURL()` for all backend API calls and routes
|
||||
- Actual API endpoints like /prompt, /queue, /view, etc.
|
||||
- Image previews: `api.apiURL('/view?...')`
|
||||
- Any backend-generated content or dynamic routes
|
||||
* Use `api.fileURL()` for static files served from the public folder:
|
||||
- Templates: `api.fileURL('/templates/default.json')`
|
||||
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
|
||||
- Any static assets that exist in the public directory
|
||||
- When implementing code that outputs raw HTML (e.g., using v-html directive), always ensure dynamic content has been properly sanitized with DOMPurify or validated through trusted sources. Prefer Vue templates over v-html when possible.
|
||||
- For any async operations (API calls, timers, etc), implement cleanup/cancellation in component unmount to prevent memory leaks
|
||||
- Extract complex template conditionals into separate components or computed properties
|
||||
- Error messages should be actionable and user-friendly (e.g., "Failed to load data. Please refresh the page." instead of "Unknown error")
|
||||
|
||||
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 |
@@ -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-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.24.0-0",
|
||||
"version": "1.24.0-1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.16.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-1",
|
||||
"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",
|
||||
|
||||
179
scripts/check-unused-i18n-keys.ts
Executable file
179
scripts/check-unused-i18n-keys.ts
Executable file
@@ -0,0 +1,179 @@
|
||||
#!/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
|
||||
]
|
||||
|
||||
// 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 {
|
||||
// Common patterns for i18n key usage
|
||||
const patterns = [
|
||||
// Direct usage: $t('key'), t('key'), i18n.t('key')
|
||||
new RegExp(`[t$]\\s*\\(\\s*['"\`]${key}['"\`]`, 'g'),
|
||||
// With namespace: $t('g.key'), t('namespace.key')
|
||||
new RegExp(
|
||||
`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${key.split('.').pop()}['"\`]`,
|
||||
'g'
|
||||
),
|
||||
// Dynamic keys might reference parts of the key
|
||||
new RegExp(`['"\`]${key}['"\`]`, '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❌ 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('Please either use them or remove them before committing.')
|
||||
|
||||
process.exit(1)
|
||||
} else {
|
||||
// Silent success - no output needed
|
||||
}
|
||||
}
|
||||
|
||||
// Run the check
|
||||
checkNewUnusedKeys().catch((err) => {
|
||||
console.error('Error checking unused keys:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -8,6 +8,7 @@
|
||||
>
|
||||
<SplitterPanel
|
||||
v-show="sidebarPanelVisible"
|
||||
v-if="sidebarLocation === 'left'"
|
||||
class="side-bar-panel"
|
||||
:min-size="10"
|
||||
:size="20"
|
||||
@@ -31,6 +32,16 @@
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel
|
||||
v-show="sidebarPanelVisible"
|
||||
v-if="sidebarLocation === 'right'"
|
||||
class="side-bar-panel"
|
||||
:min-size="10"
|
||||
:size="20"
|
||||
>
|
||||
<slot name="side-bar-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</template>
|
||||
|
||||
@@ -44,6 +55,9 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
const unifiedWidth = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.UnifiedWidth')
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
<SelectionToolbox />
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
<RightSideToolbar />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -59,7 +58,6 @@ import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import RightSideToolbar from '@/components/sidebar/RightSideToolbar.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
@@ -80,6 +78,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'
|
||||
@@ -304,6 +303,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"
|
||||
>
|
||||
@@ -117,6 +124,7 @@ 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 { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
@@ -124,17 +132,12 @@ 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 +145,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
|
||||
|
||||
@@ -164,6 +167,12 @@ const SUBMENU_CONFIG = {
|
||||
// Composables
|
||||
const { t, locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// State
|
||||
const isSubmenuVisible = ref(false)
|
||||
@@ -174,66 +183,99 @@ let hoverTimeout: number | null = null
|
||||
// Computed
|
||||
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
||||
|
||||
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 +293,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 +328,8 @@ const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => {
|
||||
top = SUBMENU_CONFIG.OFFSET_PX
|
||||
}
|
||||
|
||||
top -= 8
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
@@ -328,7 +376,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 +434,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 +607,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;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<teleport to=".comfyui-body-right">
|
||||
<div class="right-sidebar-container" :class="{ hidden: !isVisible }">
|
||||
<div class="right-sidebar-header">
|
||||
<h3></h3>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="rightSidebarTabStore.hide()"
|
||||
/>
|
||||
</div>
|
||||
<div class="right-sidebar-content">
|
||||
<!-- Content will go here -->
|
||||
<p></p>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useRightSidebarTabStore } from '@/stores/workspace/rightSidebarTabStore'
|
||||
|
||||
const rightSidebarTabStore = useRightSidebarTabStore()
|
||||
const isVisible = computed(() => rightSidebarTabStore.isVisible)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.right-sidebar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.right-sidebar-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.right-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.right-sidebar-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.right-sidebar-content {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<teleport to=".comfyui-body-left">
|
||||
<teleport :to="teleportTarget">
|
||||
<nav class="side-tool-bar-container" :class="{ 'small-sidebar': isSmall }">
|
||||
<SidebarIcon
|
||||
v-for="tab in tabs"
|
||||
: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)"
|
||||
@@ -47,6 +48,12 @@ const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const teleportTarget = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
? '.comfyui-body-left'
|
||||
: '.comfyui-body-right'
|
||||
)
|
||||
|
||||
const isSmall = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
|
||||
)
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup sidebar-left"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': sidebarSize === 'small'
|
||||
}"
|
||||
>
|
||||
@@ -24,8 +26,9 @@
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
class="sidebar-left"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': sidebarSize === 'small'
|
||||
}"
|
||||
/>
|
||||
@@ -34,8 +37,9 @@
|
||||
<!-- WhatsNew Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<WhatsNewPopup
|
||||
class="sidebar-left"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': sidebarSize === 'small'
|
||||
}"
|
||||
/>
|
||||
@@ -69,6 +73,10 @@ const releaseStore = useReleaseStore()
|
||||
const { shouldShowRedDot } = storeToRefs(releaseStore)
|
||||
const isHelpCenterVisible = ref(false)
|
||||
|
||||
const sidebarLocation = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
|
||||
|
||||
const toggleHelpCenter = () => {
|
||||
@@ -113,6 +121,10 @@ onMounted(async () => {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.sidebar-right {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import { ComfyModelDef } from '@/stores/modelStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import ModelPreview from './ModelPreview.vue'
|
||||
@@ -61,6 +62,11 @@ const modelPreviewStyle = ref<CSSProperties>({
|
||||
left: '0px'
|
||||
})
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
const handleModelHover = async () => {
|
||||
const hoverTarget = modelContentElement.value
|
||||
if (!hoverTarget) return
|
||||
@@ -74,7 +80,11 @@ const handleModelHover = async () => {
|
||||
previewHeight > availableSpaceBelow
|
||||
? `${Math.max(0, targetRect.top - (previewHeight - availableSpaceBelow) - 20)}px`
|
||||
: `${targetRect.top - 40}px`
|
||||
modelPreviewStyle.value.left = `${targetRect.right}px`
|
||||
if (sidebarLocation.value === 'left') {
|
||||
modelPreviewStyle.value.left = `${targetRect.right}px`
|
||||
} else {
|
||||
modelPreviewStyle.value.left = `${targetRect.left - 400}px`
|
||||
}
|
||||
|
||||
await modelDef.value.load()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -71,6 +72,11 @@ const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const isBookmarked = computed(() =>
|
||||
nodeBookmarkStore.isBookmarked(nodeDef.value)
|
||||
)
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
const toggleBookmark = async () => {
|
||||
await nodeBookmarkStore.toggleBookmark(nodeDef.value)
|
||||
}
|
||||
@@ -95,7 +101,11 @@ const handleNodeHover = async () => {
|
||||
previewHeight > availableSpaceBelow
|
||||
? `${Math.max(0, targetRect.top - (previewHeight - availableSpaceBelow) - 20)}px`
|
||||
: `${targetRect.top - 40}px`
|
||||
nodePreviewStyle.value.left = `${targetRect.right}px`
|
||||
if (sidebarLocation.value === 'left') {
|
||||
nodePreviewStyle.value.left = `${targetRect.right}px`
|
||||
} else {
|
||||
nodePreviewStyle.value.left = `${targetRect.left - 400}px`
|
||||
}
|
||||
}
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -82,4 +82,9 @@ watch(
|
||||
() => nextTick(updateToastPosition),
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(
|
||||
() => settingStore.get('Comfy.Sidebar.Location'),
|
||||
() => nextTick(updateToastPosition),
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -18,19 +18,6 @@
|
||||
<Actionbar />
|
||||
<CurrentUserButton class="flex-shrink-0" />
|
||||
<BottomPanelToggleButton class="flex-shrink-0" />
|
||||
<Button
|
||||
v-tooltip="{ value: 'Toggle Right Sidebar', showDelay: 300 }"
|
||||
class="flex-shrink-0"
|
||||
:icon="
|
||||
rightSidebarTabStore.isVisible
|
||||
? 'pi pi-angle-double-right'
|
||||
: 'pi pi-angle-double-left'
|
||||
"
|
||||
severity="secondary"
|
||||
text
|
||||
aria-label="Toggle Right Sidebar"
|
||||
@click="rightSidebarTabStore.toggleVisibility()"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
|
||||
class="flex-shrink-0"
|
||||
@@ -66,7 +53,6 @@ import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useRightSidebarTabStore } from '@/stores/workspace/rightSidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import {
|
||||
electronAPI,
|
||||
@@ -77,7 +63,6 @@ import {
|
||||
|
||||
const workspaceState = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidebarTabStore = useRightSidebarTabStore()
|
||||
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
|
||||
@@ -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.08
|
||||
} 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: {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -83,8 +83,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
name: 'Sidebar location',
|
||||
type: 'combo',
|
||||
options: ['left', 'right'],
|
||||
defaultValue: 'left',
|
||||
deprecated: true
|
||||
defaultValue: 'left'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Sidebar.Size',
|
||||
@@ -748,6 +747,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,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",
|
||||
|
||||
@@ -451,6 +451,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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } from '@/utils/formatUtil'
|
||||
|
||||
export const getSettingInfo = (setting: SettingParams) => {
|
||||
const parts = setting.category || setting.id.split('.')
|
||||
@@ -83,11 +84,56 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
*/
|
||||
function getDefaultValue<K extends keyof Settings>(key: K): Settings[K] {
|
||||
const param = settingsById.value[key]
|
||||
|
||||
const versionedDefault = getVersionedDefaultValue(key, param)
|
||||
|
||||
if (versionedDefault) {
|
||||
return versionedDefault
|
||||
}
|
||||
|
||||
return typeof param?.defaultValue === 'function'
|
||||
? param.defaultValue()
|
||||
: param?.defaultValue
|
||||
}
|
||||
|
||||
function getVersionedDefaultValue<K extends keyof Settings>(
|
||||
key: K,
|
||||
param: SettingParams
|
||||
): Settings[K] | null {
|
||||
// get default versioned value, skipping if the key is 'Comfy.InstalledVersion' to prevent infinite loop
|
||||
if (param?.defaultsByInstallVersion && key !== 'Comfy.InstalledVersion') {
|
||||
const installedVersion = get('Comfy.InstalledVersion')
|
||||
|
||||
if (installedVersion) {
|
||||
const sortedVersions = Object.keys(param.defaultsByInstallVersion).sort(
|
||||
(a, b) => compareVersions(a, b)
|
||||
)
|
||||
|
||||
for (const version of sortedVersions.reverse()) {
|
||||
// Ensure the version is in a valid format before comparing
|
||||
if (!isValidVersionFormat(version)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (compareVersions(installedVersion, version) >= 0) {
|
||||
const versionedDefault = param.defaultsByInstallVersion[version]
|
||||
return typeof versionedDefault === 'function'
|
||||
? versionedDefault()
|
||||
: versionedDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isValidVersionFormat(
|
||||
version: string
|
||||
): version is `${number}.${number}.${number}` {
|
||||
return /^\d+\.\d+\.\d+$/.test(version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a setting.
|
||||
* @param setting - The setting to register.
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useRightSidebarTabStore = defineStore('rightSidebarTab', () => {
|
||||
const isVisible = ref(false)
|
||||
|
||||
const toggleVisibility = () => {
|
||||
isVisible.value = !isVisible.value
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
isVisible.value = true
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
isVisible.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
toggleVisibility,
|
||||
show,
|
||||
hide
|
||||
}
|
||||
})
|
||||
@@ -28,7 +28,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
useCommandStore().registerCommand({
|
||||
id: `Workspace.ToggleSidebarTab.${tab.id}`,
|
||||
icon: tab.icon,
|
||||
label: `Toggle ${tab.title} Sidebar`,
|
||||
label: tab.title,
|
||||
tooltip: tab.tooltip,
|
||||
versionAdded: '1.3.9',
|
||||
function: () => {
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ export interface Setting {
|
||||
export interface SettingParams extends FormItem {
|
||||
id: keyof Settings
|
||||
defaultValue: any | (() => any)
|
||||
defaultsByInstallVersion?: Record<
|
||||
`${number}.${number}.${number}`,
|
||||
any | (() => any)
|
||||
>
|
||||
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
|
||||
|
||||
@@ -317,7 +317,6 @@ const onGraphReady = () => {
|
||||
z-index: 10;
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.comfyui-body-bottom {
|
||||
|
||||
@@ -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,7 +295,7 @@ 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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.24/Run') // 0.08 * 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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