Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
97547434b0 [feat] Import-based API versioning architecture
- Replace metadata-based approach with import-based version selection
- Add dual endpoint fetching strategy (object_info + v3/object_info)
- Implement proxy-based bidirectional data synchronization
- Create version-specific type definitions (v1, v1.2, v3)
- Add data transformation pipeline between API versions
- Update extension service for version-aware invocation

This provides type-safe API versioning where extensions choose their
API version through imports, ensuring compile-time safety and zero
breaking changes for existing extensions.
2025-07-08 23:47:45 -07:00
49 changed files with 2214 additions and 1722 deletions

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

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

View File

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

View File

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

View File

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

12
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.0",
"version": "1.24.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.24.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.6",
"@comfyorg/litegraph": "^0.16.5",
"@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.6",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.6.tgz",
"integrity": "sha512-pRmJYZ39rIpGIaJAaOLicRFe3KyeNTXNAAB0+Thz8cPGpu2dBv8W6PlOu94VYNRc+pBhEwV+jJVlXb5YyAvBXQ==",
"version": "0.16.5",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.5.tgz",
"integrity": "sha512-XFjlMQj6+ONbgl9dzmFP/g8Jfohzbdgxu/j9vld0bE4DDO59v8JCPrmE32ugM/18swxuoEk6iZNWKf2PGs5X2A==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.24.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.6",
"@comfyorg/litegraph": "^0.16.5",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -17,15 +17,7 @@ const IGNORE_PATTERNS = [
/^templateWorkflows\./, // Template workflows are loaded dynamically
/^dataTypes\./, // Data types might be referenced dynamically
/^contextMenu\./, // Context menu items might be dynamic
/^color\./, // Color names might be used dynamically
// Auto-generated categories from collect-i18n-general.ts
/^menuLabels\./, // Menu labels generated from command labels
/^settingsCategories\./, // Settings categories generated from setting definitions
/^serverConfigItems\./, // Server config items generated from SERVER_CONFIG_ITEMS
/^serverConfigCategories\./, // Server config categories generated from config categories
/^nodeCategories\./, // Node categories generated from node definitions
// Setting option values that are dynamically generated
/\.options\./ // All setting options are rendered dynamically
/^color\./ // Color names might be used dynamically
]
// Get list of staged locale files
@@ -105,21 +97,17 @@ function shouldIgnoreKey(key: string): boolean {
// Search for key usage in source files
function isKeyUsed(key: string, sourceFiles: string[]): boolean {
// Escape special regex characters
const escapeRegex = (str: string) =>
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const escapedKey = escapeRegex(key)
const lastPart = key.split('.').pop()
const escapedLastPart = lastPart ? escapeRegex(lastPart) : ''
// Common patterns for i18n key usage
const patterns = [
// Direct usage: $t('key'), t('key'), i18n.t('key')
new RegExp(`[t$]\\s*\\(\\s*['"\`]${escapedKey}['"\`]`, 'g'),
new RegExp(`[t$]\\s*\\(\\s*['"\`]${key}['"\`]`, 'g'),
// With namespace: $t('g.key'), t('namespace.key')
new RegExp(`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${escapedLastPart}['"\`]`, 'g'),
new RegExp(
`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${key.split('.').pop()}['"\`]`,
'g'
),
// Dynamic keys might reference parts of the key
new RegExp(`['"\`]${escapedKey}['"\`]`, 'g')
new RegExp(`['"\`]${key}['"\`]`, 'g')
]
for (const file of sourceFiles) {

View File

@@ -54,7 +54,7 @@
</Teleport>
<!-- What's New Section -->
<section v-if="showVersionUpdates" class="whats-new-section">
<section class="whats-new-section">
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
<!-- Release Items -->
@@ -126,7 +126,6 @@ import { useI18n } from 'vue-i18n'
import { type ReleaseNote } from '@/services/releaseService'
import { useCommandStore } from '@/stores/commandStore'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
@@ -162,14 +161,13 @@ const TIME_UNITS = {
const SUBMENU_CONFIG = {
DELAY_MS: 100,
OFFSET_PX: 8,
Z_INDEX: 10001
Z_INDEX: 1002
} as const
// Composables
const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
// Emits
const emit = defineEmits<{
@@ -184,9 +182,6 @@ let hoverTimeout: number | null = null
// Computed
const hasReleases = computed(() => releaseStore.releases.length > 0)
const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')

View File

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

View File

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

View File

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

View File

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

View File

@@ -330,14 +330,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.20.3'
},
{
id: 'Comfy.Notification.ShowVersionUpdates',
category: ['Comfy', 'Notification Preferences'],
name: 'Show version updates',
tooltip: 'Show updates for new models, and major new features.',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.ConfirmClear',
category: ['Comfy', 'Workflow', 'ConfirmClear'],

View File

@@ -949,8 +949,7 @@
"Light": "Light",
"User": "User",
"Credits": "Credits",
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences"
"API Nodes": "API Nodes"
},
"serverConfigItems": {
"listen": {
@@ -1489,7 +1488,6 @@
"loadError": "Failed to load help: {error}"
},
"whatsNewPopup": {
"learnMore": "Learn more",
"noReleaseNotes": "No release notes available."
"learnMore": "Learn more"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -426,7 +426,6 @@ const zSettings = z.object({
'Comfy.NodeBadge.NodeIdBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.NodeLifeCycleBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.ShowApiPricing': z.boolean(),
'Comfy.Notification.ShowVersionUpdates': z.boolean(),
'Comfy.QueueButton.BatchCountLimit': z.number(),
'Comfy.Queue.MaxHistoryItems': z.number(),
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),

View File

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

View File

@@ -9,6 +9,7 @@ import { useSettingStore } from '@/stores/settingStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { ComfyExtension } from '@/types/comfy'
import { VersionProxies } from '@/utils/versionProxies'
export const useExtensionService = () => {
const extensionStore = useExtensionStore()
@@ -128,10 +129,132 @@ export const useExtensionService = () => {
)
}
/**
* Register extension with API version tracking
*/
const registerExtensionWithVersion = (
extension: ComfyExtension,
apiVersion: string = 'latest'
) => {
extension.apiVersion = apiVersion
registerExtension(extension)
}
/**
* Invoke extensions for a specific API version with transformed args
*/
const invokeExtensionsForVersion = async <T extends any[]>(
hook: keyof ComfyExtension,
apiVersion: string,
...args: T
) => {
const extensions = extensionStore.extensions.filter(
(ext) =>
ext.apiVersion === apiVersion ||
(!ext.apiVersion && apiVersion === 'latest')
)
for (const extension of extensions) {
if (extension[hook] && typeof extension[hook] === 'function') {
try {
const transformedArgs = transformArgsForVersion(apiVersion, args)
await (extension[hook] as (...args: T) => void | Promise<void>)(
...transformedArgs
)
} catch (error) {
console.error(
`Error in extension ${extension.name} hook ${String(hook)}:`,
error
)
}
}
}
}
/**
* Invoke extensions for all API versions with appropriate data transformation
*/
const invokeExtensionsForAllVersions = async <T extends any[]>(
hook: keyof ComfyExtension,
...args: T
) => {
const apiVersions = new Set(['latest', 'v1', 'v1_2', 'v3'])
const promises = []
for (const version of apiVersions) {
promises.push(invokeExtensionsForVersion(hook, version, ...args))
}
await Promise.all(promises)
}
/**
* Transform arguments for specific API version
*/
const transformArgsForVersion = (version: string, args: any[]) => {
return args.map((arg) => {
// If argument looks like a node definition, create appropriate proxy
if (
arg &&
typeof arg === 'object' &&
arg.name &&
typeof arg.name === 'string'
) {
try {
switch (version) {
case 'v1':
return VersionProxies.createV1Proxy(arg.name)
case 'v1_2':
return VersionProxies.createV1_2Proxy(arg.name)
case 'v3':
return VersionProxies.createV3Proxy(arg.name)
default:
return arg
}
} catch (error) {
// If proxy creation fails, return original arg
console.warn(`Failed to create proxy for node ${arg.name}:`, error)
return arg
}
}
return arg
})
}
/**
* Get extension compatibility report by API version
*/
const getExtensionVersionReport = () => {
const extensions = extensionStore.extensions
const versionGroups = extensions.reduce(
(acc, ext) => {
const version = ext.apiVersion || 'latest'
if (!acc[version]) acc[version] = []
acc[version].push(ext)
return acc
},
{} as Record<string, ComfyExtension[]>
)
return {
total: extensions.length,
versionGroups,
details: Object.entries(versionGroups).map(([version, exts]) => ({
version,
count: exts.length,
extensions: exts.map((ext) => ext.name)
}))
}
}
return {
loadExtensions,
registerExtension,
registerExtensionWithVersion,
invokeExtensions,
invokeExtensionsAsync
invokeExtensionsAsync,
invokeExtensionsForVersion,
invokeExtensionsForAllVersions,
getExtensionVersionReport
}
}

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,10 @@ export interface ComfyExtension {
* The name of the extension
*/
name: string
/**
* API version this extension is using (set automatically by import)
*/
apiVersion?: string
/**
* The commands defined by the extension
*/

View File

@@ -32,10 +32,13 @@ export interface Setting {
render: () => HTMLElement
}
export interface SettingParams<TValue = unknown> extends FormItem {
export interface SettingParams extends FormItem {
id: keyof Settings
defaultValue: any | (() => any)
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
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

View File

@@ -0,0 +1,4 @@
// Re-export all version-specific types
export * from './v1'
export * from './v1_2'
export * from './v3'

101
src/types/versions/v1.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { LGraphNode } from '@comfyorg/litegraph'
/**
* V1 API node definition interface
* This represents the current/legacy node definition format
*/
export interface ComfyNodeDefV1 {
name: string
display_name?: string
description?: string
category?: string
output_node?: boolean
input?: {
required?: Record<string, any>
optional?: Record<string, any>
hidden?: Record<string, any>
}
output?: string[]
output_is_list?: boolean[]
output_name?: string[]
output_tooltips?: string[]
python_module?: string
deprecated?: boolean
experimental?: boolean
}
/**
* V1 API extension interface
*/
export interface ComfyExtensionV1 {
name: string
apiVersion?: 'v1'
// Lifecycle hooks
init?(): void | Promise<void>
setup?(): void | Promise<void>
// Node lifecycle hooks
beforeRegisterNodeDef?(
nodeType: typeof LGraphNode,
nodeData: ComfyNodeDefV1,
app: ComfyAppV1
): void
nodeCreated?(node: LGraphNode, app: ComfyAppV1): void
// Graph hooks
beforeConfigureGraph?(graphData: any, missingNodeTypes: any[]): void
afterConfigureGraph?(missingNodeTypes: any[]): void
// Canvas hooks
getCustomWidgets?(): Record<string, any>
// Menu hooks
addCustomNodeDefs?(defs: ComfyNodeDefV1[]): ComfyNodeDefV1[]
// Settings
settings?: Array<{
id: string
name: string
type: string
defaultValue: any
tooltip?: string
}>
// Commands
commands?: Array<{
id: string
function: () => void | Promise<void>
}>
// Keybindings
keybindings?: Array<{
combo: { key: string; ctrl?: boolean; shift?: boolean; alt?: boolean }
commandId: string
}>
}
/**
* V1 API ComfyApp interface
* Provides the same interface as the current app
*/
export interface ComfyAppV1 {
registerExtension(extension: ComfyExtensionV1): void
// Graph management
loadGraphData(graphData: any): Promise<void>
clean(): void
// Node management
getNodeById(id: number): LGraphNode | null
// Workflow operations
queuePrompt(number: number, batchCount?: number): Promise<void>
// Canvas operations
canvas: any
graph: any
// UI state
ui: any
}

111
src/types/versions/v1_2.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { ComfyAppV1, ComfyExtensionV1, ComfyNodeDefV1 } from './v1'
/**
* V1.2 API node definition interface
* Extends V1 with additional structured input information
*/
export interface ComfyNodeDefV1_2 extends ComfyNodeDefV1 {
inputs?: Array<{
name: string
type: string
required: boolean
options?: any
spec?: any
tooltip?: string
default?: any
}>
metadata?: {
version?: string
author?: string
description?: string
tags?: string[]
documentation?: string
}
// Enhanced output information
outputs?: Array<{
name: string
type: string
is_list: boolean
tooltip?: string
}>
}
/**
* V1.2 API extension interface
* Extends V1 with additional capabilities
*/
export interface ComfyExtensionV1_2 extends ComfyExtensionV1 {
apiVersion?: 'v1_2'
// V1.2 specific hooks
beforeRegisterNodeDef?(
nodeType: any,
nodeData: ComfyNodeDefV1_2,
app: ComfyAppV1_2
): void
nodeCreated?(node: any, app: ComfyAppV1_2): void
// Enhanced settings with validation
settings?: Array<{
id: string
name: string
type: string
defaultValue: any
tooltip?: string
validation?: (value: any) => boolean | string
category?: string
options?: any[]
}>
// Enhanced commands with descriptions
commands?: Array<{
id: string
function: () => void | Promise<void>
label?: string
tooltip?: string
category?: string
icon?: string
}>
// Bottom panel tabs
bottomPanelTabs?: Array<{
id: string
title: string
icon: string
type: 'vue' | 'custom'
component?: any
tooltip?: string
}>
// Menu items
menuItems?: Array<{
path: string[]
commands: string[]
}>
}
/**
* V1.2 API ComfyApp interface
* Extends V1 with additional functionality
*/
export interface ComfyAppV1_2 extends ComfyAppV1 {
registerExtension(extension: ComfyExtensionV1_2): void
// Enhanced node operations
createNode(type: string, title?: string, options?: any): any
removeNode(node: any): void
// Settings operations
getSetting(id: string): any
setSetting(id: string, value: any): void
// Command operations
executeCommand(id: string): Promise<void>
// Enhanced UI access
bottomPanel: any
sidebar: any
menu: any
}

288
src/types/versions/v3.ts Normal file
View File

@@ -0,0 +1,288 @@
/**
* V3 API node definition interface
* Future schema-based node definition format
*/
export interface ComfyNodeDefV3 {
name: string
display_name?: string
description?: string
category?: string
output_node?: boolean
// JSON Schema-based definition
schema: {
type: 'object'
properties: Record<string, any>
required?: string[]
additionalProperties?: boolean
}
// Structured input/output definitions
inputs: Array<{
name: string
type: string
required: boolean
schema: any
validation?: any
tooltip?: string
default?: any
ui?: {
widget?: string
options?: any
}
}>
outputs: Array<{
name: string
type: string
is_list: boolean
schema?: any
tooltip?: string
}>
// Enhanced metadata
metadata: {
version: string
author: string
description: string
tags: string[]
documentation?: string
repository?: string
license?: string
dependencies?: Array<{
name: string
version?: string
optional?: boolean
}>
}
// Execution information
execution?: {
async?: boolean
gpu_memory?: number
cpu_cores?: number
timeout?: number
retries?: number
}
python_module?: string
deprecated?: boolean
experimental?: boolean
}
/**
* V3 API extension interface
* Future extension format with enhanced capabilities
*/
export interface ComfyExtensionV3 {
name: string
apiVersion: 'v3'
// Required metadata
metadata: {
version: string
author: string
description: string
repository?: string
license?: string
dependencies?: Array<{
name: string
version?: string
optional?: boolean
}>
}
// Lifecycle hooks with enhanced data
init?(): void | Promise<void>
setup?(): void | Promise<void>
beforeShutdown?(): void | Promise<void>
// Node lifecycle hooks
beforeRegisterNodeDef?(
nodeType: any,
nodeData: ComfyNodeDefV3,
app: ComfyAppV3
): void | Promise<void>
afterRegisterNodeDef?(
nodeType: any,
nodeData: ComfyNodeDefV3,
app: ComfyAppV3
): void | Promise<void>
nodeCreated?(node: any, app: ComfyAppV3): void | Promise<void>
nodeRemoved?(node: any, app: ComfyAppV3): void | Promise<void>
// Graph hooks
beforeConfigureGraph?(
graphData: any,
missingNodeTypes: any[]
): void | Promise<void>
afterConfigureGraph?(missingNodeTypes: any[]): void | Promise<void>
graphChanged?(graph: any): void | Promise<void>
// Enhanced settings with full schema validation
settings?: Array<{
id: string
name: string
schema: any
defaultValue: any
tooltip?: string
category?: string
validation?: (value: any) => boolean | string
ui?: {
widget?: string
options?: any
}
}>
// Enhanced commands with full metadata
commands?: Array<{
id: string
function: () => void | Promise<void>
metadata: {
label: string
tooltip?: string
category?: string
icon?: string
shortcut?: string
}
validation?: () => boolean
}>
// Keybindings with enhanced options
keybindings?: Array<{
combo: {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}
commandId: string
when?: string // Context condition
priority?: number
}>
// UI extensions
uiExtensions?: {
bottomPanelTabs?: Array<{
id: string
title: string
icon: string
component: any
tooltip?: string
when?: string
}>
contextMenus?: Array<{
id: string
items: Array<{
id: string
label: string
commandId: string
when?: string
separator?: boolean
}>
}>
toolbars?: Array<{
id: string
location: 'top' | 'bottom' | 'left' | 'right'
items: Array<{
id: string
type: 'button' | 'separator' | 'dropdown'
commandId?: string
label?: string
icon?: string
tooltip?: string
}>
}>
}
// API endpoints (for extensions that provide their own APIs)
apiEndpoints?: Array<{
path: string
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
handler: (req: any, res: any) => void | Promise<void>
schema?: any
auth?: boolean
}>
}
/**
* V3 API ComfyApp interface
* Future app interface with enhanced capabilities
*/
export interface ComfyAppV3 {
registerExtension(extension: ComfyExtensionV3): Promise<void>
// Enhanced graph management
graph: {
load(data: any): Promise<void>
save(): any
clear(): void
validate(): Promise<boolean>
getNodes(): any[]
getNodeById(id: string): any | null
addNode(type: string, options?: any): Promise<any>
removeNode(node: any): Promise<void>
connectNodes(source: any, target: any, options?: any): Promise<void>
disconnectNodes(source: any, target: any): Promise<void>
}
// Enhanced workflow operations
workflow: {
queue(batchCount?: number): Promise<string>
cancel(executionId?: string): Promise<void>
pause(): Promise<void>
resume(): Promise<void>
getStatus(): any
getHistory(): any[]
getQueue(): any[]
}
// Settings management
settings: {
get<T = any>(id: string): T
set(id: string, value: any): Promise<void>
getAll(): Record<string, any>
reset(id?: string): Promise<void>
export(): any
import(data: any): Promise<void>
}
// Command system
commands: {
execute(id: string, args?: any): Promise<any>
register(command: any): void
unregister(id: string): void
getAll(): any[]
isAvailable(id: string): boolean
}
// UI management
ui: {
bottomPanel: any
sidebar: any
menu: any
canvas: any
dialogs: any
notifications: any
}
// Event system
events: {
on<T = unknown>(event: string, handler: (data: T) => void): void
off<T = unknown>(event: string, handler?: (data: T) => void): void
emit<T = unknown>(event: string, data?: T): void
once<T = unknown>(event: string, handler: (data: T) => void): void
}
// API access
api: {
get(path: string, options?: any): Promise<any>
post(path: string, data?: any, options?: any): Promise<any>
put(path: string, data?: any, options?: any): Promise<any>
delete(path: string, options?: any): Promise<any>
patch(path: string, data?: any, options?: any): Promise<any>
}
}

View File

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

519
src/utils/versionProxies.ts Normal file
View File

@@ -0,0 +1,519 @@
import type {
ComfyNodeDefLatest,
ComfyNodeDefV1,
ComfyNodeDefV1_2,
ComfyNodeDefV3
} from './versionTransforms'
/**
* Proxy-based system for synchronizing data between different API versions
*/
export class VersionProxies {
private static canonicalStore = new Map<string, ComfyNodeDefLatest>()
private static eventBus = new EventTarget()
/**
* Register a canonical node definition
*/
static registerCanonicalNode(nodeId: string, nodeData: ComfyNodeDefLatest) {
this.canonicalStore.set(nodeId, nodeData)
}
/**
* Get canonical node data
*/
static getCanonicalNode(nodeId: string): ComfyNodeDefLatest | undefined {
return this.canonicalStore.get(nodeId)
}
/**
* Create a V1 proxy for a node
*/
static createV1Proxy(nodeId: string): ComfyNodeDefV1 {
const canonical = this.canonicalStore.get(nodeId)
if (!canonical) {
throw new Error(`Node ${nodeId} not found in canonical store`)
}
return new Proxy({} as ComfyNodeDefV1, {
get(target, prop: keyof ComfyNodeDefV1) {
return VersionProxies.transformCanonicalToV1Property(canonical, prop)
},
set(target, prop: keyof ComfyNodeDefV1, value) {
VersionProxies.transformV1PropertyToCanonical(canonical, prop, value)
VersionProxies.notifyChange(nodeId, prop, value)
return true
},
has(target, prop: keyof ComfyNodeDefV1) {
return (
prop in canonical ||
prop === 'input' ||
prop === 'output' ||
prop === 'output_is_list'
)
},
ownKeys(target) {
return [
'name',
'display_name',
'description',
'category',
'output_node',
'input',
'output',
'output_is_list',
'python_module'
]
},
getOwnPropertyDescriptor(target, prop) {
return {
enumerable: true,
configurable: true,
value: this.get!(target, prop, target)
}
}
})
}
/**
* Create a V1.2 proxy for a node
*/
static createV1_2Proxy(nodeId: string): ComfyNodeDefV1_2 {
const canonical = this.canonicalStore.get(nodeId)
if (!canonical) {
throw new Error(`Node ${nodeId} not found in canonical store`)
}
return new Proxy({} as ComfyNodeDefV1_2, {
get(target, prop: keyof ComfyNodeDefV1_2) {
return VersionProxies.transformCanonicalToV1_2Property(canonical, prop)
},
set(target, prop: keyof ComfyNodeDefV1_2, value) {
VersionProxies.transformV1_2PropertyToCanonical(canonical, prop, value)
VersionProxies.notifyChange(nodeId, prop, value)
return true
},
has(target, prop: keyof ComfyNodeDefV1_2) {
return (
prop in canonical ||
prop === 'input' ||
prop === 'output' ||
prop === 'output_is_list' ||
prop === 'inputs' ||
prop === 'metadata'
)
},
ownKeys(target) {
return [
'name',
'display_name',
'description',
'category',
'output_node',
'input',
'output',
'output_is_list',
'inputs',
'metadata',
'python_module'
]
},
getOwnPropertyDescriptor(target, prop) {
return {
enumerable: true,
configurable: true,
value: this.get!(target, prop, target)
}
}
})
}
/**
* Create a V3 proxy for a node
*/
static createV3Proxy(nodeId: string): ComfyNodeDefV3 {
const canonical = this.canonicalStore.get(nodeId)
if (!canonical) {
throw new Error(`Node ${nodeId} not found in canonical store`)
}
return new Proxy({} as ComfyNodeDefV3, {
get(target, prop: keyof ComfyNodeDefV3) {
return VersionProxies.transformCanonicalToV3Property(canonical, prop)
},
set(target, prop: keyof ComfyNodeDefV3, value) {
VersionProxies.transformV3PropertyToCanonical(canonical, prop, value)
VersionProxies.notifyChange(nodeId, prop, value)
return true
},
has(target, prop: keyof ComfyNodeDefV3) {
return (
prop in canonical ||
prop === 'schema' ||
prop === 'inputs' ||
prop === 'outputs'
)
},
ownKeys(target) {
return [
'name',
'display_name',
'description',
'category',
'output_node',
'schema',
'inputs',
'outputs',
'python_module'
]
},
getOwnPropertyDescriptor(target, prop) {
return {
enumerable: true,
configurable: true,
value: this.get!(target, prop, target)
}
}
})
}
/**
* Transform canonical property to V1 format
*/
private static transformCanonicalToV1Property(
canonical: ComfyNodeDefLatest,
prop: keyof ComfyNodeDefV1
): any {
switch (prop) {
case 'input':
return canonical.input
? {
required: canonical.input.required,
optional: canonical.input.optional
}
: undefined
case 'output':
return canonical.output
case 'output_is_list':
return canonical.output_is_list
case 'name':
case 'display_name':
case 'description':
case 'category':
case 'output_node':
case 'python_module':
return canonical[prop]
default:
return undefined
}
}
/**
* Transform canonical property to V1.2 format
*/
private static transformCanonicalToV1_2Property(
canonical: ComfyNodeDefLatest,
prop: keyof ComfyNodeDefV1_2
): any {
switch (prop) {
case 'inputs':
if (!canonical.input) return undefined
return [
...Object.entries(canonical.input.required || {}).map(
([name, spec]) => ({
name,
type: this.inferTypeFromSpec(spec),
required: true,
spec,
options: Array.isArray(spec) ? spec : undefined
})
),
...Object.entries(canonical.input.optional || {}).map(
([name, spec]) => ({
name,
type: this.inferTypeFromSpec(spec),
required: false,
spec,
options: Array.isArray(spec) ? spec : undefined
})
)
]
case 'metadata':
return {
version: canonical.api_version,
author: 'Unknown',
description: canonical.description
}
default:
return this.transformCanonicalToV1Property(
canonical,
prop as keyof ComfyNodeDefV1
)
}
}
/**
* Transform canonical property to V3 format
*/
private static transformCanonicalToV3Property(
canonical: ComfyNodeDefLatest,
prop: keyof ComfyNodeDefV3
): any {
switch (prop) {
case 'schema': {
const requiredInputs = Object.keys(canonical.input?.required || {})
return {
type: 'object',
properties: {
...(canonical.input?.required || {}),
...(canonical.input?.optional || {})
},
required: requiredInputs
}
}
case 'inputs': {
if (!canonical.input) return []
const requiredInputs2 = Object.entries(canonical.input.required || {})
const optionalInputs = Object.entries(canonical.input.optional || {})
return [
...requiredInputs2.map(([name, spec]) => ({
name,
type: this.inferTypeFromSpec(spec),
required: true,
schema: spec
})),
...optionalInputs.map(([name, spec]) => ({
name,
type: this.inferTypeFromSpec(spec),
required: false,
schema: spec
}))
]
}
case 'outputs':
return (
canonical.output?.map((type, index) => ({
name: `output_${index}`,
type,
is_list: canonical.output_is_list?.[index] || false
})) || []
)
case 'name':
case 'display_name':
case 'description':
case 'category':
case 'output_node':
case 'python_module':
return canonical[prop]
default:
return undefined
}
}
/**
* Transform V1 property changes back to canonical format
*/
private static transformV1PropertyToCanonical(
canonical: ComfyNodeDefLatest,
prop: keyof ComfyNodeDefV1,
value: any
): void {
switch (prop) {
case 'input':
canonical.input = value
? {
required: value.required,
optional: value.optional
}
: undefined
break
case 'output':
canonical.output = value
break
case 'output_is_list':
canonical.output_is_list = value
break
case 'name':
case 'display_name':
case 'description':
case 'category':
case 'output_node':
case 'python_module':
;(canonical as any)[prop] = value
break
}
}
/**
* Transform V1.2 property changes back to canonical format
*/
private static transformV1_2PropertyToCanonical(
canonical: ComfyNodeDefLatest,
prop: keyof ComfyNodeDefV1_2,
value: any
): void {
switch (prop) {
case 'inputs':
if (Array.isArray(value)) {
const required: Record<string, any> = {}
const optional: Record<string, any> = {}
value.forEach((input) => {
if (input.required) {
required[input.name] = input.spec
} else {
optional[input.name] = input.spec
}
})
canonical.input = {
required: Object.keys(required).length > 0 ? required : undefined,
optional: Object.keys(optional).length > 0 ? optional : undefined
}
}
break
case 'metadata':
if (value && typeof value === 'object') {
canonical.api_version = value.version
canonical.description = value.description || canonical.description
}
break
default:
this.transformV1PropertyToCanonical(
canonical,
prop as keyof ComfyNodeDefV1,
value
)
}
}
/**
* Transform V3 property changes back to canonical format
*/
private static transformV3PropertyToCanonical(
canonical: ComfyNodeDefLatest,
prop: keyof ComfyNodeDefV3,
value: any
): void {
switch (prop) {
case 'schema':
if (value && typeof value === 'object') {
const required: Record<string, any> = {}
const optional: Record<string, any> = {}
Object.entries(value.properties || {}).forEach(([name, spec]) => {
if (value.required?.includes(name)) {
required[name] = spec
} else {
optional[name] = spec
}
})
canonical.input = {
required: Object.keys(required).length > 0 ? required : undefined,
optional: Object.keys(optional).length > 0 ? optional : undefined
}
}
break
case 'inputs':
if (Array.isArray(value)) {
const required: Record<string, any> = {}
const optional: Record<string, any> = {}
value.forEach((input) => {
if (input.required) {
required[input.name] = input.schema
} else {
optional[input.name] = input.schema
}
})
canonical.input = {
required: Object.keys(required).length > 0 ? required : undefined,
optional: Object.keys(optional).length > 0 ? optional : undefined
}
}
break
case 'outputs':
if (Array.isArray(value)) {
canonical.output = value.map((output) => output.type)
canonical.output_is_list = value.map((output) => output.is_list)
}
break
case 'name':
case 'display_name':
case 'description':
case 'category':
case 'output_node':
case 'python_module':
;(canonical as any)[prop] = value
break
}
}
/**
* Notify about data changes
*/
private static notifyChange(nodeId: string, prop: string, value: any): void {
this.eventBus.dispatchEvent(
new CustomEvent('nodedef-changed', {
detail: { nodeId, prop, value }
})
)
}
/**
* Add event listener for data changes
*/
static addEventListener(type: string, listener: EventListener): void {
this.eventBus.addEventListener(type, listener)
}
/**
* Remove event listener
*/
static removeEventListener(type: string, listener: EventListener): void {
this.eventBus.removeEventListener(type, listener)
}
/**
* Infer type from input specification
*/
private static inferTypeFromSpec(spec: any): string {
if (Array.isArray(spec)) {
return 'combo'
}
if (typeof spec === 'object' && spec !== null) {
if (spec.type) {
return spec.type
}
if (spec[0] === 'INT') {
return 'int'
}
if (spec[0] === 'FLOAT') {
return 'float'
}
if (spec[0] === 'STRING') {
return 'string'
}
if (spec[0] === 'BOOLEAN') {
return 'boolean'
}
}
return 'unknown'
}
}

View File

@@ -0,0 +1,250 @@
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
// Type definitions for different API versions
export interface ComfyNodeDefV1 {
name: string
display_name?: string
description?: string
category?: string
output_node?: boolean
input?: {
required?: Record<string, any>
optional?: Record<string, any>
}
output?: string[]
output_is_list?: boolean[]
python_module?: string
}
export interface ComfyNodeDefV1_2 extends ComfyNodeDefV1 {
inputs?: Array<{
name: string
type: string
required: boolean
options?: any
spec?: any
}>
metadata?: {
version?: string
author?: string
description?: string
}
}
export interface ComfyNodeDefV3 {
name: string
display_name?: string
description?: string
category?: string
output_node?: boolean
schema: {
type: 'object'
properties: Record<string, any>
required?: string[]
}
inputs: Array<{
name: string
type: string
required: boolean
schema: any
}>
outputs: Array<{
name: string
type: string
is_list: boolean
}>
python_module?: string
}
// Use current ComfyNodeDef as the canonical format
export type ComfyNodeDefLatest = ComfyNodeDef
/**
* Transforms node definitions between different API versions
*/
export class VersionTransforms {
/**
* Transform API responses to all supported versions
*/
static transformToAllVersions(currentData: any, v3Data: any = null) {
// Use current data as canonical since it's our primary source
const canonical = currentData || {}
return {
canonical,
v1: this.canonicalToV1(canonical),
v1_2: this.canonicalToV1_2(canonical),
v3: v3Data || this.canonicalToV3(canonical)
}
}
/**
* Transform canonical format to v1 format
*/
static canonicalToV1(
canonical: Record<string, ComfyNodeDefLatest>
): Record<string, ComfyNodeDefV1> {
const v1Nodes: Record<string, ComfyNodeDefV1> = {}
for (const [nodeName, nodeData] of Object.entries(canonical)) {
v1Nodes[nodeName] = {
name: nodeData.name,
display_name: nodeData.display_name,
description: nodeData.description,
category: nodeData.category,
output_node: nodeData.output_node,
python_module: nodeData.python_module,
input: nodeData.input
? {
required: nodeData.input.required,
optional: nodeData.input.optional
}
: undefined,
output: nodeData.output,
output_is_list: nodeData.output_is_list
}
}
return v1Nodes
}
/**
* Transform canonical format to v1.2 format
*/
static canonicalToV1_2(
canonical: Record<string, ComfyNodeDefLatest>
): Record<string, ComfyNodeDefV1_2> {
const v1_2Nodes: Record<string, ComfyNodeDefV1_2> = {}
for (const [nodeName, nodeData] of Object.entries(canonical)) {
const v1Node = this.canonicalToV1({ [nodeName]: nodeData })[nodeName]
v1_2Nodes[nodeName] = {
...v1Node,
inputs: nodeData.input
? [
...Object.entries(nodeData.input.required || {}).map(
([name, spec]) => ({
name,
type: this.inferTypeFromSpec(spec),
required: true,
spec,
options: Array.isArray(spec) ? spec : undefined
})
),
...Object.entries(nodeData.input.optional || {}).map(
([name, spec]) => ({
name,
type: this.inferTypeFromSpec(spec),
required: false,
spec,
options: Array.isArray(spec) ? spec : undefined
})
)
]
: undefined,
metadata: {
version: nodeData.api_version,
author: 'Unknown',
description: nodeData.description
}
}
}
return v1_2Nodes
}
/**
* Transform canonical format to v3 format
*/
static canonicalToV3(
canonical: Record<string, ComfyNodeDefLatest>
): Record<string, ComfyNodeDefV3> {
const v3Nodes: Record<string, ComfyNodeDefV3> = {}
for (const [nodeName, nodeData] of Object.entries(canonical)) {
const requiredInputs = Object.keys(nodeData.input?.required || {})
const optionalInputs = Object.keys(nodeData.input?.optional || {})
v3Nodes[nodeName] = {
name: nodeData.name,
display_name: nodeData.display_name,
description: nodeData.description,
category: nodeData.category,
output_node: nodeData.output_node,
python_module: nodeData.python_module,
schema: {
type: 'object',
properties: {
...(nodeData.input?.required || {}),
...(nodeData.input?.optional || {})
},
required: requiredInputs
},
inputs: [
...requiredInputs.map((name) => ({
name,
type: this.inferTypeFromSpec(nodeData.input!.required![name]),
required: true,
schema: nodeData.input!.required![name]
})),
...optionalInputs.map((name) => ({
name,
type: this.inferTypeFromSpec(nodeData.input!.optional![name]),
required: false,
schema: nodeData.input!.optional![name]
}))
],
outputs:
nodeData.output?.map((type, index) => ({
name: `output_${index}`,
type,
is_list: nodeData.output_is_list?.[index] || false
})) || []
}
}
return v3Nodes
}
/**
* Infer type from input specification
*/
private static inferTypeFromSpec(spec: any): string {
if (Array.isArray(spec)) {
return 'combo'
}
if (typeof spec === 'object' && spec !== null) {
if (spec.type) {
return spec.type
}
if (spec[0] === 'INT') {
return 'int'
}
if (spec[0] === 'FLOAT') {
return 'float'
}
if (spec[0] === 'STRING') {
return 'string'
}
if (spec[0] === 'BOOLEAN') {
return 'boolean'
}
}
return 'unknown'
}
/**
* Merge current and v3 data to create canonical format
*/
private static createCanonicalFormat(
currentData: any,
v3Data: any
): Record<string, ComfyNodeDefLatest> {
// For now, use current data as canonical
// In the future, we might merge v3 data for enhanced information
return currentData || {}
}
}

View File

@@ -300,14 +300,14 @@ describe('useNodePricing', () => {
})
describe('dynamic pricing - IdeogramV3', () => {
it('should return $0.09 for Quality rendering speed', () => {
it('should return $0.08 for Quality rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Quality' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.09/Run')
expect(price).toBe('$0.08/Run')
})
it('should return $0.06 for Balanced rendering speed', () => {
@@ -348,7 +348,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.27/Run') // 0.09 * 3
expect(price).toBe('$0.24/Run') // 0.08 * 3
})
it('should multiply price by num_images for Turbo rendering speed', () => {

View File

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

View File

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