Compare commits

..

5 Commits

Author SHA1 Message Date
Chenlei Hu
33dc74fa2a scroll queue item for long text 2025-05-22 10:39:11 -04:00
Chenlei Hu
bd37da7161 nit 2025-05-22 10:39:11 -04:00
Chenlei Hu
0b8d98e1e7 Add to preview gallery 2025-05-22 10:39:09 -04:00
Chenlei Hu
25a6b9f393 Copy to clipboard 2025-05-22 10:38:52 -04:00
Chenlei Hu
bdf32790c9 wip 2025-05-22 10:38:48 -04:00
77 changed files with 780 additions and 4321 deletions

View File

@@ -1,43 +0,0 @@
# Add Missing i18n Translations
## Task: Add English translations for all new localized strings
### Step 1: Identify new translation keys
Find all translation keys that were added in the current branch's changes. These keys appear as arguments to translation functions: `t()`, `st()`, `$t()`, or similar i18n functions.
### Step 2: Add translations to English locale file
For each new translation key found, add the corresponding English text to the file `src/locales/en/main.json`.
### Key-to-JSON mapping rules:
- Translation keys use dot notation to represent nested JSON structure
- Convert dot notation to nested JSON objects when adding to the locale file
- Example: The key `g.user.name` maps to:
```json
{
"g": {
"user": {
"name": "User Name"
}
}
}
```
### Important notes:
1. **Only modify the English locale file** (`src/locales/en/main.json`)
2. **Do not modify other locale files** - translations for other languages are automatically generated by the `i18n.yaml` workflow
3. **Exception for manual translations**: Only add translations to non-English locale files if:
- You have specific domain knowledge that would produce a more accurate translation than the automated system
- The automated translation would likely be incorrect due to technical terminology or context-specific meaning
### Example workflow:
1. If you added `t('settings.advanced.enable')` in a Vue component
2. Add to `src/locales/en/main.json`:
```json
{
"settings": {
"advanced": {
"enable": "Enable advanced settings"
}
}
}
```

View File

@@ -1,9 +1,7 @@
name: Bug Report
description: 'Something is not behaving as expected.'
title: '[Bug]: '
description: "Something is not behaving as expected."
title: "[Bug]: "
labels: ['Potential Bug']
type: Bug
body:
- type: markdown
attributes:
@@ -12,15 +10,8 @@ body:
- **1:** You are running the latest version of ComfyUI.
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
- type: checkboxes
id: custom-nodes-test
attributes:
label: Custom Node Testing
description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
options:
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
required: true
- **3:** You confirmed that the bug is not caused by a custom node. You can disable all custom nodes by passing
`--disable-all-custom-nodes` command line argument.
- type: textarea
attributes:

View File

@@ -1,8 +1,7 @@
name: Feature Request
description: Suggest an idea for this project
title: '[Feature Request]: '
labels: ['enhancement']
type: Feature
title: "[Feature Request]: "
labels: ["enhancement"]
body:
- type: checkboxes

View File

@@ -1,3 +1,4 @@
- use npm run to see what commands are available
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
@@ -35,4 +36,3 @@
- Follow Vue 3 style guide and naming conventions
- Use Vite for fast development and building
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.

100
README.md
View File

@@ -641,8 +641,100 @@ See [locales/README.md](src/locales/README.md) for details.
## Troubleshooting
For comprehensive troubleshooting and technical support, please refer to our official documentation:
> **Note**: For comprehensive troubleshooting and how-to guides, please refer to our [official documentation](https://docs.comfy.org/). This section covers only the most common issues related to frontend development.
- **[General Troubleshooting Guide](https://docs.comfy.org/troubleshooting/overview)** - Common issues, performance optimization, and reporting bugs
- **[Custom Node Issues](https://docs.comfy.org/troubleshooting/custom-node-issues)** - Debugging custom node problems and conflicts
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting
> **Desktop Users**: For issues specific to the desktop application, please refer to the [ComfyUI desktop repository](https://github.com/Comfy-Org/desktop).
### Debugging Custom Node (Extension) Issues
If you're experiencing crashes, errors, or unexpected behavior with ComfyUI, it's often caused by custom nodes (extensions). Follow these steps to identify and resolve the issues:
#### Step 1: Verify if custom nodes are causing the problem
Run ComfyUI with the `--disable-all-custom-nodes` flag:
```bash
python main.py --disable-all-custom-nodes
```
If the issue disappears, a custom node is the culprit. Proceed to the next step.
#### Step 2: Identify the problematic custom node using binary search
Rather than disabling nodes one by one, use this more efficient approach:
1. Temporarily move half of your custom nodes out of the `custom_nodes` directory
```bash
# Create a temporary directory
# Linux/Mac
mkdir ~/custom_nodes_disabled
# Windows
mkdir %USERPROFILE%\custom_nodes_disabled
# Move half of your custom nodes (assuming you have node1 through node8)
# Linux/Mac
mv custom_nodes/node1 custom_nodes/node2 custom_nodes/node3 custom_nodes/node4 ~/custom_nodes_disabled/
# Windows
move custom_nodes\node1 custom_nodes\node2 custom_nodes\node3 custom_nodes\node4 %USERPROFILE%\custom_nodes_disabled\
```
2. Run ComfyUI again
- If the issue persists: The problem is in nodes 5-8 (the remaining half)
- If the issue disappears: The problem is in nodes 1-4 (the moved half)
3. Let's assume the issue disappeared, so the problem is in nodes 1-4. Move half of these for the next test:
```bash
# Move nodes 3-4 back to custom_nodes
# Linux/Mac
mv ~/custom_nodes_disabled/node3 ~/custom_nodes_disabled/node4 custom_nodes/
# Windows
move %USERPROFILE%\custom_nodes_disabled\node3 %USERPROFILE%\custom_nodes_disabled\node4 custom_nodes\
```
4. Run ComfyUI again
- If the issue reappears: The problem is in nodes 3-4
- If issue still gone: The problem is in nodes 1-2
5. Let's assume the issue reappeared, so the problem is in nodes 3-4. Test each one:
```bash
# Move node 3 back to disabled
# Linux/Mac
mv custom_nodes/node3 ~/custom_nodes_disabled/
# Windows
move custom_nodes\node3 %USERPROFILE%\custom_nodes_disabled\
```
6. Run ComfyUI again
- If the issue disappears: node3 is the problem
- If issue persists: node4 is the problem
7. Repeat until you identify the specific problematic node
#### Step 3: Update or replace the problematic node
Once identified:
1. Check for updates to the problematic custom node
2. Consider alternatives with similar functionality
3. Report the issue to the custom node developer with specific details
### Common Issues and Solutions
- **"Module not found" errors**: Usually indicates missing Python dependencies. Check the custom node's `requirements.txt` file for required packages and install them:
```bash
pip install -r custom_nodes/problematic_node/requirements.txt
```
- **Frontend or Templates Package Not Updated**: After updating ComfyUI via Git, ensure you update the frontend dependencies:
```bash
pip install -r requirements.txt
```
- **Can't Find Custom Node**: Make sure to disable node validation in ComfyUI settings.
- **Error Toast About Workflow Failing Validation**: Report the issue to the ComfyUI team. As a temporary workaround, disable workflow validation in settings.
- **Login Issues When Not on Localhost**: Normal login is only available when accessing from localhost. If you're running ComfyUI via LAN, another domain, or headless, you can use our API key feature to authenticate. The API key lets you log in normally through the UI. Generate an API key at [platform.comfy.org/login](https://platform.comfy.org/login) and use it in the API Key field in the login dialog or with the `--api-key` command line argument. Refer to our [API Key Integration Guide](https://docs.comfy.org/essentials/comfyui-server/api-key-integration#integration-of-api-key-to-use-comfyui-api-nodes) for complete setup instructions.

View File

@@ -1,59 +0,0 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": null
},
{
"name": "CLIP",
"type": "CLIP",
"links": null
},
{
"name": "VAE",
"type": "VAE",
"links": null
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"models": [
{
"name": "outdated_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
},
{
"name": "another_outdated_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
}
]
},
"widgets_values": ["current_selected_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -103,7 +103,7 @@ test.describe('Missing models warning', () => {
}
])
}
await comfyPage.page.route(
comfyPage.page.route(
'**/api/experiment/models',
(route) => route.fulfill(modelFoldersRes),
{ times: 1 }
@@ -121,7 +121,7 @@ test.describe('Missing models warning', () => {
}
])
}
await comfyPage.page.route(
comfyPage.page.route(
'**/api/experiment/models/text_encoders',
(route) => route.fulfill(clipModelsRes),
{ times: 1 }
@@ -133,18 +133,6 @@ test.describe('Missing models warning', () => {
await expect(missingModelsWarning).not.toBeVisible()
})
test('Should not display warning when model metadata exists but widget values have changed', async ({
comfyPage
}) => {
// This tests the scenario where outdated model metadata exists in the workflow
// but the actual selected models (widget values) have changed
await comfyPage.loadWorkflow('model_metadata_widget_mismatch')
// The missing models warning should NOT appear
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
})
// Flaky test after parallelization
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
test.skip('Should download missing model when clicking download button', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -1,556 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
// TODO: there might be a better solution for this
// Helper function to pan canvas and select node
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
const nodePos = await nodeRef.getPosition()
await comfyPage.page.evaluate((pos) => {
const app = window['app']
const canvas = app.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
canvas.setDirty(true, true)
}, nodePos)
await comfyPage.nextFrame()
await nodeRef.click('title')
}
test.describe('Node Help', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('Selection Toolbox', () => {
test('Should open help menu for selected node', async ({ comfyPage }) => {
// Load a workflow with a node
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.loadWorkflow('default')
// Select a single node (KSampler) using node references
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found in the workflow')
}
// Select the node with panning to ensure toolbox is visible
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Wait for selection overlay container and toolbox to appear
await expect(
comfyPage.page.locator('.selection-overlay-container')
).toBeVisible()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
// Click the help button in the selection toolbox
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await expect(helpButton).toBeVisible()
await helpButton.click()
// Verify that the node library sidebar is opened
await expect(
comfyPage.menu.nodeLibraryTab.selectedTabButton
).toBeVisible()
// Verify that the help page is shown for the correct node
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler')
await expect(helpPage.locator('.node-help-content')).toBeVisible()
})
})
test.describe('Node Library Sidebar', () => {
test('Should open help menu from node library', async ({ comfyPage }) => {
// Open the node library sidebar
await comfyPage.menu.nodeLibraryTab.open()
// Wait for node library to load
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
// Search for KSampler to make it easier to find
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
'KSampler'
)
// Find the KSampler node in search results
const ksamplerNode = comfyPage.page
.locator('.tree-explorer-node-label')
.filter({ hasText: 'KSampler' })
.first()
await expect(ksamplerNode).toBeVisible()
// Hover over the node to show action buttons
await ksamplerNode.hover()
// Click the help button
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
await expect(helpButton).toBeVisible()
await helpButton.click()
// Verify that the help page is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler')
await expect(helpPage.locator('.node-help-content')).toBeVisible()
})
test('Should show node library tab when clicking back from help page', async ({
comfyPage
}) => {
// Open the node library sidebar
await comfyPage.menu.nodeLibraryTab.open()
// Wait for node library to load
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
// Search for KSampler
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
'KSampler'
)
// Find and interact with the node
const ksamplerNode = comfyPage.page
.locator('.tree-explorer-node-label')
.filter({ hasText: 'KSampler' })
.first()
await ksamplerNode.hover()
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
await helpButton.click()
// Verify help page is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler')
// Click the back button - use a more specific selector
const backButton = comfyPage.page.locator('button:has(.pi-arrow-left)')
await expect(backButton).toBeVisible()
await backButton.click()
// Verify that we're back to the node library view
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
await expect(
comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput
).toBeVisible()
// Verify help page is no longer visible
await expect(helpPage.locator('.node-help-content')).not.toBeVisible()
})
})
test.describe('Help Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
})
test('Should display loading state while fetching help', async ({
comfyPage
}) => {
// Mock slow network response
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
await route.fulfill({
status: 200,
body: '# Test Help Content\nThis is test help content.'
})
})
// Load workflow and select a node
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
// Verify loading spinner is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage.locator('.p-progressspinner')).toBeVisible()
// Wait for content to load
await expect(helpPage).toContainText('Test Help Content')
})
test('Should display fallback content when help file not found', async ({
comfyPage
}) => {
// Mock 404 response for help files
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
await route.fulfill({
status: 404,
body: 'Not Found'
})
})
// Load workflow and select a node
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
// Verify fallback content is shown (description, inputs, outputs)
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('Description')
await expect(helpPage).toContainText('Inputs')
await expect(helpPage).toContainText('Outputs')
})
test('Should render markdown with images correctly', async ({
comfyPage
}) => {
// Mock response with markdown containing images
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Documentation
![Example Image](example.jpg)
![External Image](https://example.com/image.png)
## Parameters
- **steps**: Number of steps
`
})
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler Documentation')
// Check that relative image paths are prefixed correctly
const relativeImage = helpPage.locator('img[alt="Example Image"]')
await expect(relativeImage).toBeVisible()
await expect(relativeImage).toHaveAttribute(
'src',
/.*\/docs\/KSampler\/example\.jpg/
)
// Check that absolute URLs are not modified
const externalImage = helpPage.locator('img[alt="External Image"]')
await expect(externalImage).toHaveAttribute(
'src',
'https://example.com/image.png'
)
})
test('Should render video elements with source tags in markdown', async ({
comfyPage
}) => {
// Mock response with video elements
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Demo
<video src="demo.mp4" controls autoplay></video>
<video src="/absolute/video.mp4" controls></video>
<video controls>
<source src="video.mp4" type="video/mp4">
<source src="https://example.com/video.webm" type="video/webm">
</video>
`
})
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
// Check relative video paths are prefixed
const relativeVideo = helpPage.locator('video[src*="demo.mp4"]')
await expect(relativeVideo).toBeVisible()
await expect(relativeVideo).toHaveAttribute(
'src',
/.*\/docs\/KSampler\/demo\.mp4/
)
await expect(relativeVideo).toHaveAttribute('controls', '')
await expect(relativeVideo).toHaveAttribute('autoplay', '')
// Check absolute paths are not modified
const absoluteVideo = helpPage.locator('video[src="/absolute/video.mp4"]')
await expect(absoluteVideo).toHaveAttribute('src', '/absolute/video.mp4')
// Check video source elements
const relativeVideoSource = helpPage.locator('source[src*="video.mp4"]')
await expect(relativeVideoSource).toHaveAttribute(
'src',
/.*\/docs\/KSampler\/video\.mp4/
)
const externalVideoSource = helpPage.locator(
'source[src="https://example.com/video.webm"]'
)
await expect(externalVideoSource).toHaveAttribute(
'src',
'https://example.com/video.webm'
)
})
test('Should handle custom node documentation paths', async ({
comfyPage
}) => {
// First load workflow with custom node
await comfyPage.loadWorkflow('group_node_v1.3.3')
// Mock custom node documentation with fallback
await comfyPage.page.route(
'**/extensions/*/docs/*/en.md',
async (route) => {
await route.fulfill({ status: 404 })
}
)
await comfyPage.page.route('**/extensions/*/docs/*.md', async (route) => {
await route.fulfill({
status: 200,
body: `# Custom Node Documentation
This is documentation for a custom node.
![Custom Image](assets/custom.png)
`
})
})
// Find and select a custom/group node
const nodeRefs = await comfyPage.page.evaluate(() => {
return window['app'].graph.nodes.map((n: any) => n.id)
})
if (nodeRefs.length > 0) {
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
await selectNodeWithPan(comfyPage, firstNode)
}
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
if (await helpButton.isVisible()) {
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('Custom Node Documentation')
// Check image path for custom nodes
const image = helpPage.locator('img[alt="Custom Image"]')
await expect(image).toHaveAttribute(
'src',
/.*\/extensions\/.*\/docs\/assets\/custom\.png/
)
}
})
test('Should sanitize dangerous HTML content', async ({ comfyPage }) => {
// Mock response with potentially dangerous content
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# Safe Content
<script>alert('XSS')</script>
<img src="x" onerror="alert('XSS')">
<a href="javascript:alert('XSS')">Dangerous Link</a>
<iframe src="evil.com"></iframe>
<!-- Safe content -->
<video src="safe.mp4" controls></video>
<img src="safe.jpg" alt="Safe Image">
`
})
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
// Dangerous elements should be removed
await expect(helpPage.locator('script')).toHaveCount(0)
await expect(helpPage.locator('iframe')).toHaveCount(0)
// Check that onerror attribute is removed
const images = helpPage.locator('img')
const imageCount = await images.count()
for (let i = 0; i < imageCount; i++) {
const img = images.nth(i)
const onError = await img.getAttribute('onerror')
expect(onError).toBeNull()
}
// Check that javascript: links are sanitized
const links = helpPage.locator('a')
const linkCount = await links.count()
for (let i = 0; i < linkCount; i++) {
const link = links.nth(i)
const href = await link.getAttribute('href')
if (href !== null) {
expect(href).not.toContain('javascript:')
}
}
// Safe content should remain
await expect(helpPage.locator('video[src*="safe.mp4"]')).toBeVisible()
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
これは日本語のドキュメントです。
`
})
})
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
This is English documentation.
`
})
})
// Set locale to Japanese
await comfyPage.setSetting('Comfy.Locale', 'ja')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
// Reset locale
await comfyPage.setSetting('Comfy.Locale', 'en')
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {
// Mock network error
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
await route.abort('failed')
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
// Should show fallback content (node description)
await expect(helpPage).toBeVisible()
await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible()
// Should show some content even on error
const content = await helpPage.textContent()
expect(content).toBeTruthy()
})
test('Should update help content when switching between nodes', async ({
comfyPage
}) => {
// Mock different help content for different nodes
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: '# KSampler Help\n\nThis is KSampler documentation.'
})
})
await comfyPage.page.route(
'**/docs/CheckpointLoaderSimple/en.md',
async (route) => {
await route.fulfill({
status: 200,
body: '# Checkpoint Loader Help\n\nThis is Checkpoint Loader documentation.'
})
}
)
await comfyPage.loadWorkflow('default')
// Select KSampler first
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler Help')
await expect(helpPage).toContainText('This is KSampler documentation')
// Now select Checkpoint Loader
const checkpointNodes = await comfyPage.getNodeRefsByType(
'CheckpointLoaderSimple'
)
await selectNodeWithPan(comfyPage, checkpointNodes[0])
// Click help button again
const helpButton2 = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton2.click()
// Content should update
await expect(helpPage).toContainText('Checkpoint Loader Help')
await expect(helpPage).toContainText(
'This is Checkpoint Loader documentation'
)
await expect(helpPage).not.toContainText('KSampler documentation')
})
})
})

View File

@@ -32,9 +32,7 @@ test.describe('Templates', () => {
}
})
// TODO: Re-enable this test once issue resolved
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
test.skip('should have all required thumbnail media for each template', async ({
test('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export default [
},
parser: tseslint.parser,
parserOptions: {
project: ['./tsconfig.json', './tsconfig.eslint.json'],
project: './tsconfig.json',
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions: ['.vue']

53
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.22.0",
"version": "1.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.22.0",
"version": "1.21.0",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.14",
"@comfyorg/litegraph": "^0.15.11",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -29,14 +29,12 @@
"@xterm/xterm": "^5.5.0",
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
@@ -56,7 +54,6 @@
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.44.1",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
@@ -791,9 +788,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.14.tgz",
"integrity": "sha512-9yERUwRVFPFspXowyg5z97QyF6+UbHG6ZNygvxSOisTCVSPOUeX/E02xcnhB5BHk0bTZCJGg9v2iztXBE5brnA==",
"version": "0.15.11",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.11.tgz",
"integrity": "sha512-gU8KK9cid7dXSK1yh3ReUolG0HGT3piKgKLd8YDr21PWl64pQvzy8BIh7W1vKH8ZWictKmNBaG9IRKlsJ667Zw==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -3991,16 +3988,6 @@
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@@ -4128,13 +4115,6 @@
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
@@ -6625,15 +6605,6 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -10114,18 +10085,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/marked": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.11.tgz",
"integrity": "sha512-1BEXAU2euRCG3xwgLVT1y0xbJEld1XOrmRJpUwRCcy7rxhSCwMrmEu9LXoPhHSCJG41V7YcQ2mjKRr5BA3ITIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.22.0",
"version": "1.21.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -34,7 +34,6 @@
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.44.1",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
@@ -75,7 +74,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.15.14",
"@comfyorg/litegraph": "^0.15.11",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -92,14 +91,12 @@
"@xterm/xterm": "^5.5.0",
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",

View File

@@ -30,15 +30,6 @@
@click="download.triggerBrowserDownload"
/>
</div>
<div>
<Button
:label="$t('g.copyURL')"
size="small"
outlined
:disabled="!!props.error"
@click="copyURL"
/>
</div>
</div>
</template>
@@ -47,7 +38,6 @@ import Button from 'primevue/button'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
@@ -59,15 +49,9 @@ const props = defineProps<{
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -1,293 +0,0 @@
import { Form } from '@primevue/forms'
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import ToastService from 'primevue/toastservice'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import SignInForm from './SignInForm.vue'
type ComponentInstance = InstanceType<typeof SignInForm>
// Mock firebase auth modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
sendPasswordResetEmail: vi.fn()
}))
// Mock the auth composables and stores
const mockSendPasswordReset = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
}
}))
}))
// Mock toast
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
}))
}))
describe('SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
mockToastAdd.mockReset()
mockLoading = false
})
const mountComponent = (
props = {},
options = {}
): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
},
props,
...options
})
}
describe('Forgot Password Link', () => {
it('shows disabled style when email is empty', async () => {
const wrapper = mountComponent()
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
await nextTick()
// Should show toast warning
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: enMessages.auth.login.emailPlaceholder,
life: 5000
})
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Click the forgot password link
await forgotPasswordSpan.trigger('click')
// Should call handleForgotPassword
expect(handleForgotPasswordSpy).toHaveBeenCalled()
})
})
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit directly with valid data
component.onSubmit({
valid: true,
values: { email: 'test@example.com', password: 'password123' }
})
// Check emitted event
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([
{
email: 'test@example.com',
password: 'password123'
}
])
})
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
// Should not emit submit event
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('Loading State', () => {
it('shows spinner when loading', async () => {
mockLoading = true
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
expect(wrapper.html()).not.toContain('<button')
}
})
it('shows button when not loading', () => {
mockLoading = false
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
})
describe('Component Structure', () => {
it('renders email input with correct attributes', () => {
const wrapper = mountComponent()
const emailInput = wrapper.findComponent(InputText)
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
expect(emailInput.attributes('autocomplete')).toBe('email')
expect(emailInput.attributes('name')).toBe('email')
expect(emailInput.attributes('type')).toBe('text')
})
it('renders password input with correct attributes', () => {
const wrapper = mountComponent()
const passwordInput = wrapper.findComponent(Password)
// Check props instead of attributes for Password component
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
// Password component passes name as prop, not attribute
expect(passwordInput.props('name')).toBe('password')
expect(passwordInput.props('feedback')).toBe(false)
expect(passwordInput.props('toggleMask')).toBe(true)
})
it('renders form with correct resolver', () => {
const wrapper = mountComponent()
const form = wrapper.findComponent(Form)
expect(form.props('resolver')).toBeDefined()
})
})
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
})
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
// Should NOT focus email input
expect(document.getElementById).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
// Should call sendPasswordReset
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
})
})
})

View File

@@ -7,12 +7,15 @@
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-in-email"
>
{{ t('auth.login.emailLabel') }}
</label>
<InputText
:id="emailInputId"
autocomplete="email"
pt:root:id="comfy-org-sign-in-email"
pt:root:autocomplete="email"
class="h-10"
name="email"
type="text"
@@ -34,11 +37,8 @@
{{ t('auth.login.passwordLabel') }}
</label>
<span
class="text-muted text-base font-medium cursor-pointer select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
class="text-muted text-base font-medium cursor-pointer"
@click="handleForgotPassword($form.email?.value)"
>
{{ t('auth.login.forgotPassword') }}
</span>
@@ -77,7 +77,6 @@ import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -88,7 +87,6 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
const { t } = useI18n()
@@ -96,34 +94,14 @@ const emit = defineEmits<{
submit: [values: SignInData]
}>()
const emailInputId = 'comfy-org-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
const handleForgotPassword = async (
email: string,
isValid: boolean | undefined
) => {
if (!email || !isValid) {
toast.add({
severity: 'warn',
summary: t('auth.login.emailPlaceholder'),
life: 5_000
})
// Focus the email input
document.getElementById(emailInputId)?.focus?.()
return
}
const handleForgotPassword = async (email: string) => {
if (!email) return
await firebaseAuthActions.sendPasswordReset(email)
}
</script>
<style scoped>
.text-link-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

@@ -18,7 +18,6 @@
:key="command.id"
:command="command"
/>
<HelpButton />
</Panel>
</template>
@@ -31,7 +30,6 @@ import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerBu
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'

View File

@@ -1,49 +0,0 @@
<template>
<Button
v-show="nodeDef"
v-tooltip.top="{
value: $t('g.help'),
showDelay: 1000
}"
class="help-button"
text
icon="pi pi-question-circle"
severity="secondary"
@click="showHelp"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useCanvasStore } from '@/stores/graphStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const nodeDef = computed<ComfyNodeDefImpl | null>(() => {
if (canvasStore.selectedItems.length !== 1) return null
const item = canvasStore.selectedItems[0]
if (!isLGraphNode(item)) return null
return nodeDefStore.fromLGraphNode(item)
})
const showHelp = () => {
const def = nodeDef.value
if (!def) return
if (sidebarTabStore.activeSidebarTabId !== nodeLibraryTabId) {
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
}
nodeHelpStore.openHelp(def)
}
</script>

View File

@@ -150,8 +150,8 @@ const handleStopRecording = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.stopRecording()
isRecording.value = false
hasRecording.value = true
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
@@ -294,8 +294,8 @@ const listenRecordingStatusChange = (value: boolean) => {
isRecording.value = value
if (!value && load3DSceneRef.value?.load3d) {
hasRecording.value = true
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}

View File

@@ -168,8 +168,8 @@ const handleStopRecording = () => {
if (sceneRef?.load3d) {
sceneRef.load3d.stopRecording()
isRecording.value = false
hasRecording.value = true
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
@@ -197,8 +197,8 @@ const listenRecordingStatusChange = (value: boolean) => {
if (!value) {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
hasRecording.value = true
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
}

View File

@@ -99,6 +99,8 @@ const emit = defineEmits<{
}>()
const resizeNodeMatchOutput = () => {
console.log('resizeNodeMatchOutput')
const outputWidth = node.widgets?.find((w) => w.name === 'width')
const outputHeight = node.widgets?.find((w) => w.name === 'height')

View File

@@ -166,11 +166,10 @@ const showContextMenu = (e: CanvasPointerEvent) => {
showSearchBox(e)
}
}
const afterRerouteId = firstLink.fromReroute?.id
const connectionOptions =
toType === 'input'
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
? { nodeFrom: node, slotFrom: fromSlot }
: { nodeTo: node, slotTo: fromSlot }
const canvas = canvasStore.getCanvas()
const menu = canvas.showConnectionMenu({

View File

@@ -1,124 +1,67 @@
<template>
<div class="h-full">
<SidebarTabTemplate
v-if="!isHelpOpen"
:title="$t('sideToolbar.nodeLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.newFolder')"
class="new-folder-button"
icon="pi pi-folder-plus"
text
severity="secondary"
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupBy')"
:icon="selectedGroupingIcon"
text
severity="secondary"
@click="groupingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortMode')"
:icon="selectedSortingIcon"
text
severity="secondary"
@click="sortingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
icon="pi pi-refresh"
text
severity="secondary"
@click="resetOrganization"
/>
<Popover ref="groupingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in groupingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedGroupingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectGrouping(option.id)"
/>
</div>
</Popover>
<Popover ref="sortingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in sortingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedSortingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectSorting(option.id)"
/>
</div>
</Popover>
</template>
<template #header>
<div>
<SearchBox
v-model:modelValue="searchQuery"
class="node-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
@search="handleSearch"
@show-filter="($event) => searchFilter?.toggle($event)"
@remove-filter="onRemoveFilter"
/>
<SidebarTabTemplate
:title="$t('sideToolbar.nodeLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.newFolder')"
class="new-folder-button"
icon="pi pi-folder-plus"
text
severity="secondary"
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortOrder')"
class="sort-button"
:icon="alphabeticalSort ? 'pi pi-sort-alpha-down' : 'pi pi-sort-alt'"
text
severity="secondary"
@click="alphabeticalSort = !alphabeticalSort"
/>
</template>
<template #header>
<SearchBox
v-model:modelValue="searchQuery"
class="node-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
@search="handleSearch"
@show-filter="($event) => searchFilter?.toggle($event)"
@remove-filter="onRemoveFilter"
/>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @add-filter="onAddFilter" />
</Popover>
</div>
</template>
<template #body>
<div>
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
:open-node-help="openHelp"
/>
<Divider
v-show="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
class="m-2"
/>
<TreeExplorer
v-model:expandedKeys="expandedKeys"
class="node-lib-tree-explorer"
:root="renderedRoot"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" :open-node-help="openHelp" />
</template>
</TreeExplorer>
</div>
</template>
</SidebarTabTemplate>
<NodeHelpPage v-else :node="currentHelpNode!" @close="closeHelp" />
</div>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @add-filter="onAddFilter" />
</Popover>
</template>
<template #body>
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-show="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
class="m-2"
/>
<TreeExplorer
v-model:expandedKeys="expandedKeys"
class="node-lib-tree-explorer"
:root="renderedRoot"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
</template>
</SidebarTabTemplate>
<div id="node-library-node-preview-container" />
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
@@ -130,31 +73,24 @@ import TreeExplorer from '@/components/common/TreeExplorer.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useLitegraphService } from '@/services/litegraphService'
import {
DEFAULT_GROUPING_ID,
DEFAULT_SORTING_ID,
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import type {
GroupingStrategyId,
SortingStrategyId
} from '@/types/nodeOrganizationTypes'
import {
ComfyNodeDefImpl,
buildNodeDefTree,
useNodeDefStore
} from '@/stores/nodeDefStore'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { FuseFilterWithValue } from '@/utils/fuseUtil'
import { sortedTree } from '@/utils/treeUtil'
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeHelpStore = useNodeHelpStore()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
@@ -162,70 +98,13 @@ const nodeBookmarkTreeExplorerRef = ref<InstanceType<
typeof NodeBookmarkTreeExplorer
> | null>(null)
const searchFilter = ref<InstanceType<typeof Popover> | null>(null)
const groupingPopover = ref<InstanceType<typeof Popover> | null>(null)
const sortingPopover = ref<InstanceType<typeof Popover> | null>(null)
const selectedGroupingId = useLocalStorage<GroupingStrategyId>(
'Comfy.NodeLibrary.GroupBy',
DEFAULT_GROUPING_ID
)
const selectedSortingId = useLocalStorage<SortingStrategyId>(
'Comfy.NodeLibrary.SortBy',
DEFAULT_SORTING_ID
)
const alphabeticalSort = ref(false)
const searchQuery = ref<string>('')
const { currentHelpNode, isHelpOpen } = storeToRefs(nodeHelpStore)
const { openHelp, closeHelp } = nodeHelpStore
const groupingOptions = computed(() =>
nodeOrganizationService.getGroupingStrategies().map((strategy) => ({
id: strategy.id,
label: strategy.label,
icon: strategy.icon
}))
)
const sortingOptions = computed(() =>
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
id: strategy.id,
label: strategy.label,
icon: strategy.icon
}))
)
const selectedGroupingIcon = computed(() =>
nodeOrganizationService.getGroupingIcon(selectedGroupingId.value)
)
const selectedSortingIcon = computed(() =>
nodeOrganizationService.getSortingIcon(selectedSortingId.value)
)
const selectGrouping = (groupingId: string) => {
selectedGroupingId.value = groupingId as GroupingStrategyId
groupingPopover.value?.hide()
}
const selectSorting = (sortingId: string) => {
selectedSortingId.value = sortingId as SortingStrategyId
sortingPopover.value?.hide()
}
const resetOrganization = () => {
selectedGroupingId.value = DEFAULT_GROUPING_ID
selectedSortingId.value = DEFAULT_SORTING_ID
}
const root = computed(() => {
// Determine which nodes to use
const nodes =
filteredNodeDefs.value.length > 0
? filteredNodeDefs.value
: nodeDefStore.visibleNodeDefs
// Use the service to organize nodes
return nodeOrganizationService.organizeNodes(nodes, {
groupBy: selectedGroupingId.value,
sortBy: selectedSortingId.value
})
const root = filteredRoot.value || nodeDefStore.nodeTree
return alphabeticalSort.value ? sortedTree(root, { groupLeaf: true }) : root
})
const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
@@ -265,6 +144,12 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
})
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
const filteredRoot = computed<TreeNode | null>(() => {
if (!filteredNodeDefs.value.length) {
return null
}
return buildNodeDefTree(filteredNodeDefs.value)
})
const filters: Ref<
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
> = ref([])
@@ -290,10 +175,8 @@ const handleSearch = async (query: string) => {
)
await nextTick()
// Expand the search results tree
if (filteredNodeDefs.value.length > 0) {
expandNode(root.value)
}
// @ts-expect-error fixme ts strict error
expandNode(filteredRoot.value)
}
const onAddFilter = async (

View File

@@ -9,7 +9,7 @@
<NodeTreeFolder :node="node" />
</template>
<template #node="{ node }">
<NodeTreeLeaf :node="node" :open-node-help="props.openNodeHelp" />
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
@@ -43,7 +43,6 @@ import type {
const props = defineProps<{
filteredNodeDefs: ComfyNodeDefImpl[]
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
}>()
const expandedKeys = ref<Record<string, boolean>>({})

View File

@@ -1,230 +0,0 @@
<template>
<div class="flex flex-col h-full bg-[var(--p-tree-background)] overflow-auto">
<div
class="px-3 py-2 flex items-center border-b border-[var(--p-divider-color)]"
>
<Button
v-tooltip.bottom="$t('g.back')"
icon="pi pi-arrow-left"
text
severity="secondary"
@click="$emit('close')"
/>
<span class="ml-2 font-semibold">{{ node.display_name }}</span>
</div>
<div class="p-4 flex-grow node-help-content max-w-[600px] mx-auto">
<ProgressSpinner
v-if="isLoading"
class="m-auto"
aria-label="Loading help"
/>
<!-- Markdown fetched successfully -->
<div
v-else-if="!error"
class="markdown-content"
v-html="renderedHelpHtml"
/>
<!-- Fallback: markdown not found or fetch error -->
<div v-else class="text-sm space-y-6 fallback-content">
<p v-if="node.description">
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
</p>
<div v-if="inputList.length">
<p>
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
</p>
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="input in inputList" :key="input.name">
<td>
<code>{{ input.name }}</code>
</td>
<td>{{ input.type }}</td>
<td>{{ input.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="outputList.length">
<p>
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
</p>
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="output in outputList" :key="output.name">
<td>
<code>{{ output.name }}</code>
</td>
<td>{{ output.type }}</td>
<td>{{ output.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
const nodeHelpStore = useNodeHelpStore()
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
defineEmits<{
(e: 'close'): void
}>()
const inputList = computed(() =>
Object.values(node.inputs).map((spec) => ({
name: spec.name,
type: spec.type,
tooltip: spec.tooltip || ''
}))
)
const outputList = computed(() =>
node.outputs.map((spec) => ({
name: spec.name,
type: spec.type,
tooltip: spec.tooltip || ''
}))
)
</script>
<style scoped lang="postcss">
.node-help-content :deep(:is(img, video)) {
@apply max-w-full h-auto block mb-4;
}
.markdown-content,
.fallback-content {
@apply text-sm;
}
.markdown-content :deep(h1),
.fallback-content h1 {
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h2),
.fallback-content h2 {
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h3),
.fallback-content h3 {
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6),
.fallback-content h4,
.fallback-content h5,
.fallback-content h6 {
@apply mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(td),
.fallback-content td {
color: var(--drag-text);
}
.markdown-content :deep(a),
.fallback-content a {
color: var(--drag-text);
text-decoration: underline;
}
.markdown-content :deep(th),
.fallback-content th {
color: var(--fg-color);
}
.markdown-content :deep(ul),
.markdown-content :deep(ol),
.fallback-content ul,
.fallback-content ol {
@apply pl-8 my-2;
}
.markdown-content :deep(ul ul),
.markdown-content :deep(ol ol),
.markdown-content :deep(ul ol),
.markdown-content :deep(ol ul),
.fallback-content ul ul,
.fallback-content ol ol,
.fallback-content ul ol,
.fallback-content ol ul {
@apply pl-6 my-2;
}
.markdown-content :deep(li),
.fallback-content li {
@apply my-1;
}
.markdown-content :deep(*:first-child),
.fallback-content > *:first-child {
@apply mt-0;
}
.markdown-content :deep(code),
.fallback-content code {
@apply text-[var(--error-text)] bg-[var(--content-bg)] rounded px-1 py-0.5;
}
.markdown-content :deep(table),
.fallback-content table {
@apply w-full border-collapse;
}
.markdown-content :deep(th),
.markdown-content :deep(td),
.fallback-content th,
.fallback-content td {
@apply px-2 py-2;
}
.markdown-content :deep(tr),
.fallback-content tr {
border-bottom: 1px solid var(--content-bg);
}
.markdown-content :deep(tr:last-child),
.fallback-content tr:last-child {
border-bottom: none;
}
.markdown-content :deep(thead),
.fallback-content thead {
border-bottom: 1px solid var(--p-text-color);
}
</style>

View File

@@ -22,15 +22,6 @@
severity="secondary"
@click.stop="toggleBookmark"
/>
<Button
v-tooltip.bottom="$t('g.learnMore')"
class="help-button"
size="small"
icon="pi pi-question"
text
severity="secondary"
@click.stop="props.openNodeHelp(nodeDef)"
/>
</template>
</TreeExplorerTreeNode>
@@ -63,7 +54,6 @@ import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const props = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
}>()
// Note: node.data should be present for leaf nodes.

View File

@@ -36,6 +36,7 @@
/>
<ResultVideo v-else-if="item.isVideo" :result="item" />
<ResultAudio v-else-if="item.isAudio" :result="item" />
<ResultText v-else-if="item.isText" :result="item" />
</template>
</Galleria>
</template>
@@ -48,12 +49,13 @@ import ComfyImage from '@/components/common/ComfyImage.vue'
import { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultText from './ResultText.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)
const emit = defineEmits<{
(e: 'update:activeIndex', value: number): void
'update:activeIndex': [number]
}>()
const props = defineProps<{

View File

@@ -13,6 +13,7 @@
/>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<ResultAudio v-else-if="result.isAudio" :result="result" />
<ResultText v-else-if="result.isText" :result="result" />
<div v-else class="task-result-preview">
<i class="pi pi-file" />
<span>{{ result.mediaType }}</span>
@@ -28,6 +29,7 @@ import { ResultItemImpl } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import ResultAudio from './ResultAudio.vue'
import ResultText from './ResultText.vue'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{

View File

@@ -0,0 +1,81 @@
<template>
<div class="result-text-container">
<div class="text-content">
{{ result.text }}
</div>
<Button
class="copy-button"
icon="pi pi-copy"
text
@click.stop="copyToClipboard(result.text ?? '')"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { ResultItemImpl } from '@/stores/queueStore'
defineProps<{
result: ResultItemImpl
}>()
const { copyToClipboard } = useCopyToClipboard()
</script>
<style scoped>
.result-text-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
padding: 1rem;
text-align: center;
word-break: break-word;
}
.text-content {
font-size: 0.875rem;
color: var(--text-color);
width: 100%;
max-height: 100%;
max-width: 80vw;
overflow-y: auto;
line-height: 1.5;
padding-right: 0.5rem;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* Hide scrollbar but keep functionality */
.text-content::-webkit-scrollbar {
width: 0;
background: transparent;
}
.copy-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
opacity: 0;
transition: opacity 0.2s ease;
color: var(--text-color-secondary);
padding: 0.25rem;
border-radius: 0.25rem;
background-color: var(--surface-ground);
}
.result-text-container:hover .copy-button {
opacity: 1;
}
.copy-button:hover {
background-color: var(--surface-hover);
}
</style>

View File

@@ -50,11 +50,9 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
// Mock the useFirebaseAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
fetchBalance: vi.fn().mockResolvedValue(undefined)
}))
}))
@@ -102,7 +100,8 @@ describe('CurrentUserPopover', () => {
global: {
plugins: [i18n],
stubs: {
Divider: true
Divider: true,
Button: true
}
}
})
@@ -115,18 +114,6 @@ describe('CurrentUserPopover', () => {
expect(wrapper.text()).toContain('test@example.com')
})
it('renders logout button with correct props', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[1]
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
})
it('opens user settings and emits close event when settings button is clicked', async () => {
const wrapper = mountComponent()
@@ -145,30 +132,12 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('calls logout function and emits close event when logout button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[1]
// Click the logout button
await logoutButton.trigger('click')
// Verify logout was called
expect(mockLogout).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the API pricing button (third one now)
// Find all buttons and get the API pricing button (second one)
const buttons = wrapper.findAllComponents(Button)
const apiPricingButton = buttons[2]
const apiPricingButton = buttons[1]
// Click the API pricing button
await apiPricingButton.trigger('click')

View File

@@ -37,18 +37,6 @@
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
text
fluid
severity="secondary"
@click="handleLogout"
/>
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('credits.apiPricing')"
@@ -102,11 +90,6 @@ const handleTopUp = () => {
emit('close')
}
const handleLogout = async () => {
await authActions.logout()
emit('close')
}
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
emit('close')

View File

@@ -1,16 +1,11 @@
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type {
IBaseWidget,
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
@@ -240,61 +235,34 @@ const renderPreview = (
}
}
class ImagePreviewWidget extends BaseWidget {
constructor(
class ImagePreviewWidget implements ICustomWidget {
readonly type: 'custom'
readonly name: string
readonly options: IWidgetOptions<string | object>
/** Dummy value to satisfy type requirements. */
value: string
y: number = 0
/** Don't serialize the widget value. */
serialize: boolean = false
constructor(name: string, options: IWidgetOptions<string | object>) {
this.type = 'custom'
this.name = name
this.options = options
this.value = ''
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
_width: number,
y: number,
_height: number
): void {
renderPreview(ctx, node, y)
}
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
computeLayoutSize(this: IBaseWidget) {
return {
minHeight: 220,
minWidth: 1
@@ -308,7 +276,7 @@ export const useImagePreviewWidget = () => {
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
new ImagePreviewWidget(inputSpec.name, {
serialize: false
})
)

View File

@@ -28,8 +28,7 @@ export const useTextPreviewWidget = (
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false
getMinHeight: () => options.minHeight ?? 42 + PADDING
}
})
addWidget(node, widget)

View File

@@ -266,7 +266,7 @@ useExtensionService().registerExtension({
LOAD_3D_ANIMATION(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.gltf,.glb,.fbx'
fileInput.accept = '.fbx,glb,gltf'
fileInput.style.display = 'none'
fileInput.onchange = async () => {
if (fileInput.files?.length) {
@@ -452,43 +452,31 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
let filePath = message.result[0]
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
let cameraState = message.result[1]
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
useLoad3dService().waitForLoad3d(node, (load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
const config = new Load3DConfiguration(load3d)
config.configure('output', modelWidget, cameraState)
}
}
})
})
}
}
})
@@ -538,42 +526,29 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
let filePath = message.result[0]
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
let cameraState = message.result[1]
useLoad3dService().waitForLoad3d(node, (load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
const config = new Load3DConfiguration(load3d)
config.configure('output', modelWidget, cameraState)
}
}
})
})
}
}
})

View File

@@ -132,14 +132,6 @@ export class LoaderManager implements LoaderManagerInterface {
if (this.modelManager.materialMode === 'original') {
const mtlUrl = url.replace(/(filename=.*?)\.obj/, '$1.mtl')
const subfolderMatch = url.match(/[?&]subfolder=([^&]*)/)
const subfolder = subfolderMatch
? decodeURIComponent(subfolderMatch[1])
: '3d'
this.mtlLoader.setSubfolder(subfolder)
try {
const materials = await this.mtlLoader.loadAsync(mtlUrl)
materials.preload()

View File

@@ -38,10 +38,6 @@ class OverrideMTLLoader extends Loader {
this.loadRootFolder = loadRootFolder
}
setSubfolder(subfolder) {
this.subfolder = subfolder
}
/**
* Starts loading from the given URL and passes the loaded MTL asset
* to the `onLoad()` callback.
@@ -139,8 +135,7 @@ class OverrideMTLLoader extends Loader {
const materialCreator = new OverrideMaterialCreator(
this.resourcePath || path,
this.materialOptions,
this.loadRootFolder,
this.subfolder
this.loadRootFolder
)
materialCreator.setCrossOrigin(this.crossOrigin)
materialCreator.setManager(this.manager)
@@ -160,7 +155,7 @@ class OverrideMTLLoader extends Loader {
*/
class OverrideMaterialCreator {
constructor(baseUrl = '', options = {}, loadRootFolder, subfolder) {
constructor(baseUrl = '', options = {}, loadRootFolder) {
this.baseUrl = baseUrl
this.options = options
this.materialsInfo = {}
@@ -169,7 +164,6 @@ class OverrideMaterialCreator {
this.nameLookup = {}
this.loadRootFolder = loadRootFolder
this.subfolder = subfolder
this.crossOrigin = 'anonymous'
@@ -289,25 +283,16 @@ class OverrideMaterialCreator {
/**
* Override for ComfyUI api url
*/
function resolveURL(baseUrl, url, loadRootFolder, subfolder) {
function resolveURL(baseUrl, url, loadRootFolder) {
if (typeof url !== 'string' || url === '') return ''
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1)
}
if (!baseUrl.endsWith('api')) {
baseUrl = '/api'
}
baseUrl =
baseUrl +
'/view?filename=' +
url +
'&type=' +
loadRootFolder +
'&subfolder=' +
subfolder
'&subfolder=3d'
return baseUrl
}
@@ -317,12 +302,7 @@ class OverrideMaterialCreator {
const texParams = scope.getTextureParams(value, params)
const map = scope.loadTexture(
resolveURL(
scope.baseUrl,
texParams.url,
scope.loadRootFolder,
scope.subfolder
)
resolveURL(scope.baseUrl, texParams.url, scope.loadRootFolder)
)
map.repeat.copy(texParams.scale)

View File

@@ -30,7 +30,6 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"help": "Help",
"loading": "Loading",
"findIssues": "Find Issues",
"reportIssue": "Send Report",
@@ -122,8 +121,7 @@
"edit": "Edit",
"copy": "Copy",
"imageUrl": "Image URL",
"clear": "Clear",
"copyURL": "Copy URL"
"clear": "Clear"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -417,23 +415,7 @@
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
"nodeLibraryTab": {
"groupBy": "Group By",
"sortMode": "Sort Mode",
"resetView": "Reset View to Default",
"groupStrategies": {
"category": "Category",
"categoryDesc": "Group by node category",
"module": "Module",
"moduleDesc": "Group by module source",
"source": "Source",
"sourceDesc": "Group by source type (Core, Custom, API)"
},
"sortBy": {
"original": "Original",
"originalDesc": "Keep original order",
"alphabetical": "Alphabetical",
"alphabeticalDesc": "Sort alphabetically within groups"
}
"sortOrder": "Sort Order"
},
"modelLibrary": "Model Library",
"downloads": "Downloads",
@@ -1424,13 +1406,5 @@
"cancelEditTooltip": "Cancel edit",
"copiedTooltip": "Copied",
"copyTooltip": "Copy message to clipboard"
},
"nodeHelpPage": {
"inputs": "Inputs",
"outputs": "Outputs",
"type": "Type",
"moreHelp": "For more help, visit the",
"documentationPage": "documentation page",
"loadError": "Failed to load help: {error}"
}
}

View File

@@ -268,7 +268,6 @@
"control_before_generate": "control antes de generar",
"copy": "Copiar",
"copyToClipboard": "Copiar al portapapeles",
"copyURL": "Copiar URL",
"currentUser": "Usuario actual",
"customBackground": "Fondo personalizado",
"customize": "Personalizar",
@@ -294,7 +293,6 @@
"findIssues": "Encontrar problemas",
"firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menú > Usar nuevo menú > Desactivado\" para restaurar la antigua interfaz.",
"goToNode": "Ir al nodo",
"help": "Ayuda",
"icon": "Icono",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageUrl": "URL de la imagen",
@@ -837,14 +835,6 @@
"video": "video",
"video_models": "modelos_de_video"
},
"nodeHelpPage": {
"documentationPage": "página de documentación",
"inputs": "Entradas",
"loadError": "Error al cargar la ayuda: {error}",
"moreHelp": "Para más ayuda, visita la",
"outputs": "Salidas",
"type": "Tipo"
},
"nodeTemplates": {
"enterName": "Introduzca el nombre",
"saveAsTemplate": "Guardar como plantilla"
@@ -1074,23 +1064,7 @@
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
"nodeLibrary": "Biblioteca de nodos",
"nodeLibraryTab": {
"groupBy": "Agrupar por",
"groupStrategies": {
"category": "Categoría",
"categoryDesc": "Agrupar por categoría de nodo",
"module": "Módulo",
"moduleDesc": "Agrupar por fuente del módulo",
"source": "Fuente",
"sourceDesc": "Agrupar por tipo de fuente (Core, Custom, API)"
},
"resetView": "Restablecer vista a la predeterminada",
"sortBy": {
"alphabetical": "Alfabético",
"alphabeticalDesc": "Ordenar alfabéticamente dentro de los grupos",
"original": "Original",
"originalDesc": "Mantener el orden original"
},
"sortMode": "Modo de ordenación"
"sortOrder": "Orden de clasificación"
},
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
"queue": "Cola",

View File

@@ -3403,7 +3403,7 @@
"clear": {
},
"height": {
"name": "alto"
"name": "altura"
},
"image": {
"name": "imagen"
@@ -3417,26 +3417,20 @@
"name": "ancho"
}
},
"outputs": {
"0": {
"name": "imagen"
},
"1": {
"name": "mask"
},
"2": {
"name": "ruta_malla"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "normal"
},
"4": {
{
"name": "lineart"
},
"5": {
"name": "info_cámara"
{
"name": "camera_info"
}
}
]
},
"Load3DAnimation": {
"display_name": "Cargar 3D - Animación",
@@ -3444,7 +3438,7 @@
"clear": {
},
"height": {
"name": "alto"
"name": "altura"
},
"image": {
"name": "imagen"
@@ -3458,23 +3452,17 @@
"name": "ancho"
}
},
"outputs": {
"0": {
"name": "imagen"
},
"1": {
"name": "mask"
},
"2": {
"name": "ruta_malla"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "normal"
},
"4": {
"name": "info_cámara"
{
"name": "camera_info"
}
}
]
},
"LoadAudio": {
"display_name": "CargarAudio",

View File

@@ -268,7 +268,6 @@
"control_before_generate": "contrôle avant génération",
"copy": "Copier",
"copyToClipboard": "Copier dans le presse-papiers",
"copyURL": "Copier lURL",
"currentUser": "Utilisateur actuel",
"customBackground": "Arrière-plan personnalisé",
"customize": "Personnaliser",
@@ -294,7 +293,6 @@
"findIssues": "Trouver des problèmes",
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
"goToNode": "Aller au nœud",
"help": "Aide",
"icon": "Icône",
"imageFailedToLoad": "Échec du chargement de l'image",
"imageUrl": "URL de l'image",
@@ -837,14 +835,6 @@
"video": "vidéo",
"video_models": "modèles_vidéo"
},
"nodeHelpPage": {
"documentationPage": "page de documentation",
"inputs": "Entrées",
"loadError": "Échec du chargement de laide : {error}",
"moreHelp": "Pour plus d'aide, visitez la",
"outputs": "Sorties",
"type": "Type"
},
"nodeTemplates": {
"enterName": "Entrez le nom",
"saveAsTemplate": "Enregistrer comme modèle"
@@ -1074,23 +1064,7 @@
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
"nodeLibrary": "Bibliothèque de nœuds",
"nodeLibraryTab": {
"groupBy": "Grouper par",
"groupStrategies": {
"category": "Catégorie",
"categoryDesc": "Grouper par catégorie de nœud",
"module": "Module",
"moduleDesc": "Grouper par source du module",
"source": "Source",
"sourceDesc": "Grouper par type de source (Core, Custom, API)"
},
"resetView": "Réinitialiser la vue par défaut",
"sortBy": {
"alphabetical": "Alphabétique",
"alphabeticalDesc": "Trier alphabétiquement dans les groupes",
"original": "Original",
"originalDesc": "Conserver l'ordre d'origine"
},
"sortMode": "Mode de tri"
"sortOrder": "Ordre de tri"
},
"openWorkflow": "Ouvrir le flux de travail dans le système de fichiers local",
"queue": "File d'attente",

View File

@@ -3417,26 +3417,20 @@
"name": "largeur"
}
},
"outputs": {
"0": {
"name": "image"
},
"1": {
"name": "masque"
},
"2": {
"name": "chemin_maillage"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "normale"
},
"4": {
"name": "lineart"
{
"name": "ligne artistique"
},
"5": {
"name": "info_caméra"
{
"name": "informations caméra"
}
}
]
},
"Load3DAnimation": {
"display_name": "Charger 3D - Animation",
@@ -3458,23 +3452,17 @@
"name": "largeur"
}
},
"outputs": {
"0": {
"name": "image"
"outputs": [
null,
null,
null,
{
"name": "normal"
},
"1": {
"name": "masque"
},
"2": {
"name": "chemin_maillage"
},
"3": {
"name": "normale"
},
"4": {
"name": "info_caméra"
{
"name": "camera_info"
}
}
]
},
"LoadAudio": {
"display_name": "ChargerAudio",

View File

@@ -268,7 +268,6 @@
"control_before_generate": "生成前の制御",
"copy": "コピー",
"copyToClipboard": "クリップボードにコピー",
"copyURL": "URLをコピー",
"currentUser": "現在のユーザー",
"customBackground": "カスタム背景",
"customize": "カスタマイズ",
@@ -294,7 +293,6 @@
"findIssues": "問題を見つける",
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
"goToNode": "ノードに移動",
"help": "ヘルプ",
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imageUrl": "画像URL",
@@ -837,14 +835,6 @@
"video": "ビデオ",
"video_models": "ビデオモデル"
},
"nodeHelpPage": {
"documentationPage": "ドキュメントページ",
"inputs": "入力",
"loadError": "ヘルプの読み込みに失敗しました: {error}",
"moreHelp": "さらに詳しい情報は、",
"outputs": "出力",
"type": "タイプ"
},
"nodeTemplates": {
"enterName": "名前を入力",
"saveAsTemplate": "テンプレートとして保存"
@@ -1074,23 +1064,7 @@
"newBlankWorkflow": "新しい空のワークフローを作成",
"nodeLibrary": "ノードライブラリ",
"nodeLibraryTab": {
"groupBy": "グループ化",
"groupStrategies": {
"category": "カテゴリ",
"categoryDesc": "ノードカテゴリでグループ化",
"module": "モジュール",
"moduleDesc": "モジュールソースでグループ化",
"source": "ソース",
"sourceDesc": "ソースタイプCore、Custom、APIでグループ化"
},
"resetView": "ビューをデフォルトにリセット",
"sortBy": {
"alphabetical": "アルファベット順",
"alphabeticalDesc": "グループ内でアルファベット順に並び替え",
"original": "元の順序",
"originalDesc": "元の順序を維持"
},
"sortMode": "並び替えモード"
"sortOrder": "並び順"
},
"openWorkflow": "ローカルでワークフローを開く",
"queue": "キュー",

View File

@@ -3417,29 +3417,23 @@
"name": "幅"
}
},
"outputs": {
"0": {
"name": "画像"
},
"1": {
"name": "マスク"
},
"2": {
"name": "メッシュパス"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "法線"
},
"4": {
{
"name": "線画"
},
"5": {
{
"name": "カメラ情報"
}
}
]
},
"Load3DAnimation": {
"display_name": "3D読み込 - アニメーション",
"display_name": "3D読み込 - アニメーション",
"inputs": {
"clear": {
},
@@ -3458,23 +3452,17 @@
"name": "幅"
}
},
"outputs": {
"0": {
"name": "画像"
},
"1": {
"name": "マスク"
},
"2": {
"name": "メッシュパス"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "法線"
},
"4": {
{
"name": "カメラ情報"
}
}
]
},
"LoadAudio": {
"display_name": "音声を読み込む",

View File

@@ -268,7 +268,6 @@
"control_before_generate": "생성 전 제어",
"copy": "복사",
"copyToClipboard": "클립보드에 복사",
"copyURL": "URL 복사",
"currentUser": "현재 사용자",
"customBackground": "맞춤 배경",
"customize": "사용자 정의",
@@ -294,7 +293,6 @@
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"goToNode": "노드로 이동",
"help": "도움말",
"icon": "아이콘",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"imageUrl": "이미지 URL",
@@ -837,14 +835,6 @@
"video": "비디오",
"video_models": "비디오 모델"
},
"nodeHelpPage": {
"documentationPage": "문서 페이지",
"inputs": "입력",
"loadError": "도움말을 불러오지 못했습니다: {error}",
"moreHelp": "더 많은 도움말은",
"outputs": "출력",
"type": "유형"
},
"nodeTemplates": {
"enterName": "이름 입력",
"saveAsTemplate": "템플릿으로 저장"
@@ -1074,23 +1064,7 @@
"newBlankWorkflow": "새 빈 워크플로 만들기",
"nodeLibrary": "노드 라이브러리",
"nodeLibraryTab": {
"groupBy": "그룹 기준",
"groupStrategies": {
"category": "카테고리",
"categoryDesc": "노드 카테고리별로 그룹화",
"module": "모듈",
"moduleDesc": "모듈 소스별로 그룹화",
"source": "소스",
"sourceDesc": "소스 유형(Core, Custom, API)별로 그룹화"
},
"resetView": "기본 보기로 재설정",
"sortBy": {
"alphabetical": "알파벳순",
"alphabeticalDesc": "그룹 내에서 알파벳순으로 정렬",
"original": "원본 순서",
"originalDesc": "원래 순서를 유지"
},
"sortMode": "정렬 방식"
"sortOrder": "정렬 순서"
},
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
"queue": "실행 대기열",

View File

@@ -3398,7 +3398,7 @@
}
},
"Load3D": {
"display_name": "3D 불러오기",
"display_name": "3D 로드",
"inputs": {
"clear": {
},
@@ -3417,29 +3417,23 @@
"name": "너비"
}
},
"outputs": {
"0": {
"name": "이미지"
},
"1": {
"name": "마스크"
},
"2": {
"name": "메시 경로"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "노멀"
},
"4": {
{
"name": "라인아트"
},
"5": {
{
"name": "카메라 정보"
}
}
]
},
"Load3DAnimation": {
"display_name": "3D 불러오기 - 애니메이션",
"display_name": "3D 로드 - 애니메이션",
"inputs": {
"clear": {
},
@@ -3458,23 +3452,17 @@
"name": "너비"
}
},
"outputs": {
"0": {
"name": "이미지"
},
"1": {
"name": "마스크"
},
"2": {
"name": "메시 경로"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "노멀"
},
"4": {
{
"name": "카메라 정보"
}
}
]
},
"LoadAudio": {
"display_name": "오디오 로드",

View File

@@ -268,7 +268,6 @@
"control_before_generate": "управление до генерации",
"copy": "Копировать",
"copyToClipboard": "Скопировать в буфер обмена",
"copyURL": "Скопировать URL",
"currentUser": "Текущий пользователь",
"customBackground": "Пользовательский фон",
"customize": "Настроить",
@@ -294,7 +293,6 @@
"findIssues": "Найти проблемы",
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
"goToNode": "Перейти к ноде",
"help": "Помощь",
"icon": "Иконка",
"imageFailedToLoad": "Не удалось загрузить изображение",
"imageUrl": "URL изображения",
@@ -837,14 +835,6 @@
"video": "видео",
"video_models": "видеомодели"
},
"nodeHelpPage": {
"documentationPage": "страницу документации",
"inputs": "Входы",
"loadError": "Не удалось загрузить справку: {error}",
"moreHelp": "Для получения дополнительной помощи посетите",
"outputs": "Выходы",
"type": "Тип"
},
"nodeTemplates": {
"enterName": "Введите название",
"saveAsTemplate": "Сохранить как шаблон"
@@ -1074,23 +1064,7 @@
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
"nodeLibrary": "Библиотека нод",
"nodeLibraryTab": {
"groupBy": "Группировать по",
"groupStrategies": {
"category": "Категория",
"categoryDesc": "Группировать по категории узла",
"module": "Модуль",
"moduleDesc": "Группировать по источнику модуля",
"source": "Источник",
"sourceDesc": "Группировать по типу источника (Core, Custom, API)"
},
"resetView": "Сбросить вид по умолчанию",
"sortBy": {
"alphabetical": "По алфавиту",
"alphabeticalDesc": "Сортировать по алфавиту внутри групп",
"original": "Оригинальный порядок",
"originalDesc": "Сохранять исходный порядок"
},
"sortMode": "Режим сортировки"
"sortOrder": "Порядок сортировки"
},
"openWorkflow": "Открыть рабочий процесс в локальной файловой системе",
"queue": "Очередь",

View File

@@ -3409,7 +3409,7 @@
"name": "изображение"
},
"model_file": {
"name": "файл модели"
"name": "файл_модели"
},
"upload 3d model": {
},
@@ -3417,29 +3417,23 @@
"name": "ширина"
}
},
"outputs": {
"0": {
"name": "изображение"
},
"1": {
"name": "mask"
},
"2": {
"name": "путь к mesh"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "нормаль"
},
"4": {
"name": "линейный рисунок"
{
"name": "линеарт"
},
"5": {
"name": "информация о камере"
{
"name": "информация_камеры"
}
}
]
},
"Load3DAnimation": {
"display_name": "Загрузить 3D - Анимация",
"display_name": "Загрузить 3D Анимация",
"inputs": {
"clear": {
},
@@ -3458,23 +3452,17 @@
"name": "ширина"
}
},
"outputs": {
"0": {
"name": "изображение"
},
"1": {
"name": "mask"
},
"2": {
"name": "путь_к_модели"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "нормаль"
},
"4": {
"name": "информация_о_камере"
{
"name": "информация_камеры"
}
}
]
},
"LoadAudio": {
"display_name": "Загрузить аудио",

View File

@@ -268,7 +268,6 @@
"control_before_generate": "生成前控制",
"copy": "复制",
"copyToClipboard": "复制到剪贴板",
"copyURL": "复制链接",
"currentUser": "当前用户",
"customBackground": "自定义背景",
"customize": "自定义",
@@ -294,7 +293,6 @@
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"goToNode": "转到节点",
"help": "帮助",
"icon": "图标",
"imageFailedToLoad": "图像加载失败",
"imageUrl": "图片网址",
@@ -837,14 +835,6 @@
"video": "视频",
"video_models": "视频模型"
},
"nodeHelpPage": {
"documentationPage": "文档页面",
"inputs": "输入",
"loadError": "加载帮助失败:{error}",
"moreHelp": "如需更多帮助,请访问",
"outputs": "输出",
"type": "类型"
},
"nodeTemplates": {
"enterName": "输入名称",
"saveAsTemplate": "另存为模板"
@@ -1074,23 +1064,7 @@
"newBlankWorkflow": "创建空白工作流",
"nodeLibrary": "节点库",
"nodeLibraryTab": {
"groupBy": "分组方式",
"groupStrategies": {
"category": "类别",
"categoryDesc": "按节点类别分组",
"module": "模块",
"moduleDesc": "按模块来源分组",
"source": "来源",
"sourceDesc": "按来源类型分组核心自定义API"
},
"resetView": "重置视图为默认",
"sortBy": {
"alphabetical": "字母顺序",
"alphabeticalDesc": "在分组内按字母顺序排序",
"original": "原始顺序",
"originalDesc": "保持原始顺序"
},
"sortMode": "排序模式"
"sortOrder": "排序顺序"
},
"openWorkflow": "在本地文件系统中打开工作流",
"queue": "队列",

View File

@@ -3417,26 +3417,20 @@
"name": "宽度"
}
},
"outputs": {
"0": {
"name": "image"
"outputs": [
null,
null,
null,
{
"name": "法线"
},
"1": {
"name": "mask"
{
"name": "线稿"
},
"2": {
"name": "mesh_path"
},
"3": {
"name": "normal"
},
"4": {
"name": "lineart"
},
"5": {
"name": "camera_info"
{
"name": "相机信息"
}
}
]
},
"Load3DAnimation": {
"display_name": "加载3D动画",
@@ -3458,23 +3452,17 @@
"name": "宽度"
}
},
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "遮罩"
},
"2": {
"name": "mesh_path"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "法线"
},
"4": {
{
"name": "相机信息"
}
}
]
},
"LoadAudio": {
"display_name": "加载音频",

View File

@@ -22,7 +22,8 @@ const zOutputs = z
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional(),
video: z.array(zResultItem).optional(),
animated: z.array(z.boolean()).optional()
animated: z.array(z.boolean()).optional(),
text: z.string().optional()
})
.passthrough()

View File

@@ -73,7 +73,6 @@ export const zComfyNodeDef = z.object({
name: z.string(),
display_name: z.string(),
description: z.string(),
help: z.string().optional(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),

View File

@@ -219,7 +219,6 @@ export const zComfyNodeDef = z.object({
name: z.string(),
display_name: z.string(),
description: z.string(),
help: z.string().optional(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
@@ -228,7 +227,7 @@ export const zComfyNodeDef = z.object({
/**
* Whether the node is an API node. Running API nodes requires login to
* Comfy Org account.
* https://docs.comfy.org/tutorials/api-nodes/overview
* https://www.comfy.org/faq
*/
api_node: z.boolean().optional()
})

View File

@@ -30,6 +30,12 @@ import {
isComboInputSpecV1,
isComboInputSpecV2
} from '@/schemas/nodeDefSchema'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
import { getMp3Metadata } from '@/scripts/metadata/mp3'
import { getOggMetadata } from '@/scripts/metadata/ogg'
import { getSvgMetadata } from '@/scripts/metadata/svg'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
@@ -52,7 +58,6 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import { ExtensionManager } from '@/types/extensionTypes'
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import { getFileHandler } from '@/utils/fileHandlers'
import {
executeWidgetsCallback,
fixLinkInputSlots,
@@ -62,13 +67,18 @@ import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
import { defaultGraph } from './defaultGraph'
import { pruneWidgets } from './domWidget'
import { importA1111 } from './pnginfo'
import {
getFlacMetadata,
getLatentMetadata,
getPngMetadata,
getWebpMetadata,
importA1111
} from './pnginfo'
import { $el, ComfyUI } from './ui'
import { ComfyAppMenu } from './ui/menu/index'
import { clone } from './utils'
@@ -1027,10 +1037,8 @@ export class ComfyApp {
}
// Collect models metadata from node
const selectedModels = getSelectedModelsMetadata(n)
if (selectedModels?.length) {
embeddedModels.push(...selectedModels)
}
if (n.properties?.models?.length)
embeddedModels.push(...n.properties.models)
}
// Merge models from the workflow's root-level 'models' field
@@ -1066,11 +1074,11 @@ export class ComfyApp {
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
if (
restore_view &&
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
) {
if (graphData.extra?.ds) {
if (restore_view) {
if (
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
graphData.extra?.ds
) {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
} else {
@@ -1273,44 +1281,161 @@ export class ComfyApp {
return f.substring(0, p)
}
const fileName = removeExt(file.name)
// Get the appropriate file handler for this file type
const fileHandler = getFileHandler(file)
if (!fileHandler) {
// No handler found for this file type
this.showErrorOnFileLoad(file)
return
}
try {
// Process the file using the handler
const { workflow, prompt, parameters, jsonTemplateData } =
await fileHandler(file)
if (workflow) {
// We have a workflow, load it
await this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
// We have a prompt in API format, load it
this.loadApiJson(prompt, fileName)
} else if (parameters) {
// We have A1111 parameters, import them
if (file.type === 'image/png') {
const pngInfo = await getPngMetadata(file)
if (pngInfo?.workflow) {
await this.loadGraphData(
JSON.parse(pngInfo.workflow),
true,
true,
fileName
)
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
} else if (pngInfo?.parameters) {
// Note: Not putting this in `importA1111` as it is mostly not used
// by external callers, and `importA1111` has no access to `app`.
useWorkflowService().beforeLoadNewGraph()
importA1111(this.graph, parameters)
importA1111(this.graph, pngInfo.parameters)
useWorkflowService().afterLoadNewGraph(
fileName,
this.graph.serialize() as unknown as ComfyWorkflowJSON
)
} else if (jsonTemplateData) {
// We have template data from JSON
this.loadTemplateData(jsonTemplateData)
} else {
// No usable data found in the file
this.showErrorOnFileLoad(file)
}
} catch (error) {
console.error('Error processing file:', error)
} else if (file.type === 'image/webp') {
const pngInfo = await getWebpMetadata(file)
// Support loading workflows from that webp custom node.
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'audio/mpeg') {
const { workflow, prompt } = await getMp3Metadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'audio/ogg') {
const { workflow, prompt } = await getOggMetadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'audio/flac' || file.type === 'audio/x-flac') {
const pngInfo = await getFlacMetadata(file)
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'video/webm') {
const webmInfo = await getFromWebmFile(file)
if (webmInfo.workflow) {
this.loadGraphData(webmInfo.workflow, true, true, fileName)
} else if (webmInfo.prompt) {
this.loadApiJson(webmInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'video/mp4' ||
file.name?.endsWith('.mp4') ||
file.name?.endsWith('.mov') ||
file.name?.endsWith('.m4v') ||
file.type === 'video/quicktime' ||
file.type === 'video/x-m4v'
) {
const mp4Info = await getFromIsobmffFile(file)
if (mp4Info.workflow) {
this.loadGraphData(mp4Info.workflow, true, true, fileName)
} else if (mp4Info.prompt) {
this.loadApiJson(mp4Info.prompt, fileName)
}
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
const svgInfo = await getSvgMetadata(file)
if (svgInfo.workflow) {
this.loadGraphData(svgInfo.workflow, true, true, fileName)
} else if (svgInfo.prompt) {
this.loadApiJson(svgInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'model/gltf-binary' ||
file.name?.endsWith('.glb')
) {
const gltfInfo = await getGltfBinaryMetadata(file)
if (gltfInfo.workflow) {
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
} else if (gltfInfo.prompt) {
this.loadApiJson(gltfInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'application/json' ||
file.name?.endsWith('.json')
) {
const reader = new FileReader()
reader.onload = async () => {
const readerResult = reader.result as string
const jsonContent = JSON.parse(readerResult)
if (jsonContent?.templates) {
this.loadTemplateData(jsonContent)
} else if (this.isApiJson(jsonContent)) {
this.loadApiJson(jsonContent, fileName)
} else {
await this.loadGraphData(
JSON.parse(readerResult),
true,
true,
fileName
)
}
}
reader.readAsText(file)
} else if (
file.name?.endsWith('.latent') ||
file.name?.endsWith('.safetensors')
) {
const info = await getLatentMetadata(file)
// TODO define schema to LatentMetadata
// @ts-expect-error
if (info.workflow) {
await this.loadGraphData(
// @ts-expect-error
JSON.parse(info.workflow),
true,
true,
fileName
)
// @ts-expect-error
} else if (info.prompt) {
// @ts-expect-error
this.loadApiJson(JSON.parse(info.prompt))
} else {
this.showErrorOnFileLoad(file)
}
} else {
this.showErrorOnFileLoad(file)
}
}

View File

@@ -32,7 +32,7 @@ export class ChangeTracker {
/**
* Whether the redo/undo restoring is in progress.
*/
_restoringState: boolean = false
private restoringState: boolean = false
ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any>
@@ -55,7 +55,7 @@ export class ChangeTracker {
*/
reset(state?: ComfyWorkflowJSON) {
// Do not reset the state if we are restoring.
if (this._restoringState) return
if (this.restoringState) return
logger.debug('Reset State')
if (state) this.activeState = clone(state)
@@ -124,7 +124,7 @@ export class ChangeTracker {
const prevState = source.pop()
if (prevState) {
target.push(this.activeState)
this._restoringState = true
this.restoringState = true
try {
await app.loadGraphData(prevState, false, false, this.workflow, {
showMissingModelsDialog: false,
@@ -134,7 +134,7 @@ export class ChangeTracker {
this.activeState = prevState
this.updateModified()
} finally {
this._restoringState = false
this.restoringState = false
}
}
}

View File

@@ -137,8 +137,6 @@ export const useColorPaletteService = () => {
'--bg-img',
`url('${backgroundImage}') no-repeat center /cover`
)
} else {
rootStyle.removeProperty('--bg-img')
}
}
}

View File

@@ -379,24 +379,6 @@ export const useDialogService = () => {
})
}
/**
* Shows a dialog from a third party extension.
* @param options - The dialog options.
* @param options.key - The dialog key.
* @param options.title - The dialog title.
* @param options.headerComponent - The dialog header component.
* @param options.footerComponent - The dialog footer component.
* @param options.component - The dialog component.
* @param options.props - The dialog props.
* @returns The dialog instance and a function to close the dialog.
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
return {
dialog: dialogStore.showExtensionDialog(options),
closeDialog: () => dialogStore.closeDialog({ key: options.key })
}
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -412,7 +394,6 @@ export const useDialogService = () => {
showSignInDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showExtensionDialog,
prompt,
confirm
}

View File

@@ -1,59 +0,0 @@
import { api } from '@/scripts/api'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { extractCustomNodeName } from '@/utils/nodeHelpUtil'
export class NodeHelpService {
async fetchNodeHelp(node: ComfyNodeDefImpl, locale: string): Promise<string> {
const nodeSource = getNodeSource(node.python_module)
if (nodeSource.type === NodeSourceType.CustomNodes) {
return this.fetchCustomNodeHelp(node, locale)
} else {
return this.fetchCoreNodeHelp(node, locale)
}
}
private async fetchCustomNodeHelp(
node: ComfyNodeDefImpl,
locale: string
): Promise<string> {
const customNodeName = extractCustomNodeName(node.python_module)
if (!customNodeName) {
throw new Error('Invalid custom node module')
}
// Try locale-specific path first
const localePath = `/extensions/${customNodeName}/docs/${node.name}/${locale}.md`
let res = await fetch(api.fileURL(localePath))
if (!res.ok) {
// Fall back to non-locale path
const fallbackPath = `/extensions/${customNodeName}/docs/${node.name}.md`
res = await fetch(api.fileURL(fallbackPath))
}
if (!res.ok) {
throw new Error(res.statusText)
}
return res.text()
}
private async fetchCoreNodeHelp(
node: ComfyNodeDefImpl,
locale: string
): Promise<string> {
const mdUrl = `/docs/${node.name}/${locale}.md`
const res = await fetch(api.fileURL(mdUrl))
if (!res.ok) {
throw new Error(res.statusText)
}
return res.text()
}
}
// Export singleton instance
export const nodeHelpService = new NodeHelpService()

View File

@@ -1,159 +0,0 @@
import { ComfyNodeDefImpl, buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
NodeGroupingStrategy,
NodeOrganizationOptions,
NodeSortStrategy
} from '@/types/nodeOrganizationTypes'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { sortedTree } from '@/utils/treeUtil'
const DEFAULT_ICON = 'pi pi-sort'
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export class NodeOrganizationService {
private readonly groupingStrategies: NodeGroupingStrategy[] = [
{
id: 'category',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.category',
icon: 'pi pi-folder',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.categoryDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const category = nodeDef.category || ''
const categoryParts = category ? category.split('/') : []
return [...categoryParts, nodeDef.name]
}
},
{
id: 'module',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.module',
icon: 'pi pi-box',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.moduleDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const pythonModule = nodeDef.python_module || ''
if (!pythonModule) {
return ['unknown_module', nodeDef.name]
}
// Split the module path into components
const parts = pythonModule.split('.')
// Remove common prefixes and organize
if (parts[0] === 'nodes') {
// Core nodes - just use 'core'
return ['core', nodeDef.name]
} else if (parts[0] === 'custom_nodes') {
// Custom nodes - use the package name as the folder
if (parts.length > 1) {
// Return the custom node package name
return [parts[1], nodeDef.name]
}
return ['custom_nodes', nodeDef.name]
}
// For other modules, use the full path structure plus node name
return [...parts, nodeDef.name]
}
},
{
id: 'source',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.source',
icon: 'pi pi-server',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.sourceDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
if (nodeDef.api_node) {
return ['API nodes', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.Core) {
return ['Core', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.CustomNodes) {
return ['Custom nodes', nodeDef.name]
} else {
return ['Unknown', nodeDef.name]
}
}
}
]
private readonly sortingStrategies: NodeSortStrategy[] = [
{
id: 'original',
label: 'sideToolbar.nodeLibraryTab.sortBy.original',
icon: 'pi pi-sort-alt',
description: 'sideToolbar.nodeLibraryTab.sortBy.originalDesc',
compare: () => 0
},
{
id: 'alphabetical',
label: 'sideToolbar.nodeLibraryTab.sortBy.alphabetical',
icon: 'pi pi-sort-alpha-down',
description: 'sideToolbar.nodeLibraryTab.sortBy.alphabeticalDesc',
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) =>
(a.display_name ?? '').localeCompare(b.display_name ?? '')
}
]
getGroupingStrategies(): NodeGroupingStrategy[] {
return [...this.groupingStrategies]
}
getGroupingStrategy(id: string): NodeGroupingStrategy | undefined {
return this.groupingStrategies.find((strategy) => strategy.id === id)
}
getSortingStrategies(): NodeSortStrategy[] {
return [...this.sortingStrategies]
}
getSortingStrategy(id: string): NodeSortStrategy | undefined {
return this.sortingStrategies.find((strategy) => strategy.id === id)
}
organizeNodes(
nodes: ComfyNodeDefImpl[],
options: NodeOrganizationOptions = {}
): TreeNode {
const { groupBy = DEFAULT_GROUPING_ID, sortBy = DEFAULT_SORTING_ID } =
options
const groupingStrategy = this.getGroupingStrategy(groupBy)
const sortingStrategy = this.getSortingStrategy(sortBy)
if (!groupingStrategy) {
throw new Error(`Unknown grouping strategy: ${groupBy}`)
}
if (!sortingStrategy) {
throw new Error(`Unknown sorting strategy: ${sortBy}`)
}
const sortedNodes =
sortingStrategy.id !== 'original'
? [...nodes].sort(sortingStrategy.compare)
: nodes
const tree = buildNodeDefTree(sortedNodes, {
pathExtractor: groupingStrategy.getNodePath
})
if (sortBy === 'alphabetical') {
return sortedTree(tree, { groupLeaf: true })
}
return tree
}
getGroupingIcon(groupingId: string): string {
const strategy = this.getGroupingStrategy(groupingId)
return strategy?.icon || DEFAULT_ICON
}
getSortingIcon(sortingId: string): string {
const strategy = this.getSortingStrategy(sortingId)
return strategy?.icon || DEFAULT_ICON
}
}
export const nodeOrganizationService = new NodeOrganizationService()

View File

@@ -147,33 +147,10 @@ export const useDialogStore = defineStore('dialog', () => {
return dialog
}
/**
* Shows a dialog from a third party extension.
* Explicitly keys extension dialogs with `extension-` prefix,
* to avoid conflicts & prevent use of internal dialogs (available via `dialogService`).
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
const { key } = options
if (!key) {
console.error('Extension dialog key is required')
return
}
const extKey = key.startsWith('extension-') ? key : `extension-${key}`
const dialog = dialogStack.value.find((d) => d.key === extKey)
if (!dialog) return createDialog({ ...options, key: extKey })
dialog.visible = true
riseDialog(dialog)
return dialog
}
return {
dialogStack,
riseDialog,
showDialog,
closeDialog,
showExtensionDialog
closeDialog
}
})

View File

@@ -38,7 +38,6 @@ export class ComfyNodeDefImpl
category: string
readonly python_module: string
readonly description: string
readonly help: string
readonly deprecated: boolean
readonly experimental: boolean
readonly output_node: boolean
@@ -119,7 +118,6 @@ export class ComfyNodeDefImpl
this.category = obj.category
this.python_module = obj.python_module
this.description = obj.description
this.help = obj.help ?? ''
this.deprecated = obj.deprecated ?? obj.category === ''
this.experimental =
obj.experimental ?? obj.category.startsWith('_for_testing')
@@ -218,22 +216,10 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDefV1> = {
}
}
export interface BuildNodeDefTreeOptions {
/**
* Custom function to extract the tree path from a node definition.
* If not provided, uses the default path based on nodeDef.nodePath.
*/
pathExtractor?: (nodeDef: ComfyNodeDefImpl) => string[]
}
export function buildNodeDefTree(
nodeDefs: ComfyNodeDefImpl[],
options: BuildNodeDefTreeOptions = {}
): TreeNode {
const { pathExtractor } = options
const defaultPathExtractor = (nodeDef: ComfyNodeDefImpl) =>
export function buildNodeDefTree(nodeDefs: ComfyNodeDefImpl[]): TreeNode {
return buildTree(nodeDefs, (nodeDef: ComfyNodeDefImpl) =>
nodeDef.nodePath.split('/')
return buildTree(nodeDefs, pathExtractor || defaultPathExtractor)
)
}
export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {

View File

@@ -30,6 +30,7 @@ export class ResultItemImpl {
filename: string
subfolder: string
type: string
text?: string
nodeId: NodeId
// 'audio' | 'images' | ...
@@ -43,6 +44,7 @@ export class ResultItemImpl {
this.filename = obj.filename ?? ''
this.subfolder = obj.subfolder ?? ''
this.type = obj.type ?? ''
this.text = obj.text
this.nodeId = obj.nodeId
this.mediaType = obj.mediaType
@@ -193,8 +195,12 @@ export class ResultItemImpl {
)
}
get isText(): boolean {
return ['text', 'json', 'display_text'].includes(this.mediaType)
}
get supportsPreview(): boolean {
return this.isImage || this.isVideo || this.isAudio
return this.isImage || this.isVideo || this.isAudio || this.isText
}
}
@@ -233,14 +239,21 @@ export class TaskItemImpl {
}
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as ResultItem[]).map(
(item: ResultItem) =>
new ResultItemImpl({
(items as (ResultItem | string)[]).map((item: ResultItem | string) => {
if (typeof item === 'string') {
return new ResultItemImpl({
text: item,
nodeId,
mediaType
})
} else {
return new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
}
})
)
)
}

View File

@@ -23,7 +23,7 @@ export class ComfyWorkflow extends UserFile {
/**
* Whether the workflow has been modified comparing to the initial state.
*/
_isModified: boolean = false
private _isModified: boolean = false
/**
* @param options The path, modified, and size of the workflow.
@@ -131,7 +131,7 @@ export interface WorkflowStore {
activeWorkflow: LoadedComfyWorkflow | null
isActive: (workflow: ComfyWorkflow) => boolean
openWorkflows: ComfyWorkflow[]
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
openedWorkflowIndexShift: (shift: number) => LoadedComfyWorkflow | null
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
openWorkflowsInBackground: (paths: {
left?: string[]
@@ -477,7 +477,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
isSubgraphActive,
updateActiveGraph
}
}) satisfies () => WorkflowStore
}) as () => WorkflowStore
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
const bookmarks = ref<Set<string>>(new Set())

View File

@@ -1,69 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { i18n } from '@/i18n'
import { nodeHelpService } from '@/services/nodeHelpService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { getNodeHelpBaseUrl } from '@/utils/nodeHelpUtil'
export const useNodeHelpStore = defineStore('nodeHelp', () => {
const currentHelpNode = ref<ComfyNodeDefImpl | null>(null)
const isHelpOpen = computed(() => currentHelpNode.value !== null)
const helpContent = ref<string>('')
const isLoading = ref<boolean>(false)
const errorMsg = ref<string | null>(null)
function openHelp(nodeDef: ComfyNodeDefImpl) {
currentHelpNode.value = nodeDef
}
function closeHelp() {
currentHelpNode.value = null
}
// Base URL for relative assets in node docs markdown
const baseUrl = computed(() => {
const node = currentHelpNode.value
if (!node) return ''
return getNodeHelpBaseUrl(node)
})
// Watch for help node changes and fetch its docs markdown
watch(
() => currentHelpNode.value,
async (node) => {
helpContent.value = ''
errorMsg.value = null
if (node) {
isLoading.value = true
try {
const locale = i18n.global.locale.value || 'en'
helpContent.value = await nodeHelpService.fetchNodeHelp(node, locale)
} catch (e: any) {
errorMsg.value = e.message
helpContent.value = node.description || ''
} finally {
isLoading.value = false
}
}
},
{ immediate: true }
)
const renderedHelpHtml = computed(() => {
return renderMarkdownToHtml(helpContent.value, baseUrl.value)
})
return {
currentHelpNode,
isHelpOpen,
openHelp,
closeHelp,
baseUrl,
renderedHelpHtml,
isLoading,
error: errorMsg
}
})

View File

@@ -1,44 +0,0 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
export type GroupingStrategyId = 'category' | 'module' | 'source'
export type SortingStrategyId = 'original' | 'alphabetical'
/**
* Strategy for grouping nodes into tree structure
*/
export interface NodeGroupingStrategy {
/** Unique identifier for the grouping strategy */
id: string
/** Display name for UI (i18n key) */
label: string
/** Icon class for the grouping option */
icon: string
/** Description for tooltips (i18n key) */
description?: string
/** Function to extract the tree path from a node definition */
getNodePath: (nodeDef: ComfyNodeDefImpl) => string[]
}
/**
* Strategy for sorting nodes within groups
*/
export interface NodeSortStrategy {
/** Unique identifier for the sort strategy */
id: string
/** Display name for UI (i18n key) */
label: string
/** Icon class for the sort option */
icon: string
/** Description for tooltips (i18n key) */
description?: string
/** Compare function for sorting nodes within the same group */
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) => number
}
/**
* Options for organizing nodes
*/
export interface NodeOrganizationOptions {
groupBy?: string
sortBy?: string
}

View File

@@ -1,336 +0,0 @@
/**
* Maps MIME types and file extensions to handler functions for extracting
* workflow data from various file formats. Uses supportedWorkflowFormats.ts
* as the source of truth for supported formats.
*/
import {
AUDIO_WORKFLOW_FORMATS,
DATA_WORKFLOW_FORMATS,
IMAGE_WORKFLOW_FORMATS,
MODEL_WORKFLOW_FORMATS,
VIDEO_WORKFLOW_FORMATS
} from '@/constants/supportedWorkflowFormats'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/schemas/comfyWorkflowSchema'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
import { getMp3Metadata } from '@/scripts/metadata/mp3'
import { getOggMetadata } from '@/scripts/metadata/ogg'
import { getSvgMetadata } from '@/scripts/metadata/svg'
import {
getFlacMetadata,
getLatentMetadata,
getPngMetadata,
getWebpMetadata
} from '@/scripts/pnginfo'
/**
* Type for the file handler function
*/
export type WorkflowFileHandler = (file: File) => Promise<{
workflow?: ComfyWorkflowJSON
prompt?: ComfyApiWorkflow
parameters?: string
jsonTemplateData?: any // For template JSON data
}>
/**
* Maps MIME types to file handlers for loading workflows from different file formats
*/
export const mimeTypeHandlers = new Map<string, WorkflowFileHandler>()
/**
* Maps file extensions to file handlers for loading workflows
* Used as a fallback when MIME type detection fails
*/
export const extensionHandlers = new Map<string, WorkflowFileHandler>()
/**
* Handler for PNG files
*/
const handlePngFile: WorkflowFileHandler = async (file) => {
const pngInfo = await getPngMetadata(file)
return {
workflow: pngInfo?.workflow ? JSON.parse(pngInfo.workflow) : undefined,
prompt: pngInfo?.prompt ? JSON.parse(pngInfo.prompt) : undefined,
parameters: pngInfo?.parameters
}
}
/**
* Handler for WebP files
*/
const handleWebpFile: WorkflowFileHandler = async (file) => {
const pngInfo = await getWebpMetadata(file)
// Support loading workflows from that webp custom node.
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
return {
workflow: workflow ? JSON.parse(workflow) : undefined,
prompt: prompt ? JSON.parse(prompt) : undefined
}
}
/**
* Handler for SVG files
*/
const handleSvgFile: WorkflowFileHandler = async (file) => {
const svgInfo = await getSvgMetadata(file)
return {
workflow: svgInfo.workflow,
prompt: svgInfo.prompt
}
}
/**
* Handler for MP3 files
*/
const handleMp3File: WorkflowFileHandler = async (file) => {
const { workflow, prompt } = await getMp3Metadata(file)
return { workflow, prompt }
}
/**
* Handler for OGG files
*/
const handleOggFile: WorkflowFileHandler = async (file) => {
const { workflow, prompt } = await getOggMetadata(file)
return { workflow, prompt }
}
/**
* Handler for FLAC files
*/
const handleFlacFile: WorkflowFileHandler = async (file) => {
const pngInfo = await getFlacMetadata(file)
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
return {
workflow: workflow ? JSON.parse(workflow) : undefined,
prompt: prompt ? JSON.parse(prompt) : undefined
}
}
/**
* Handler for WebM files
*/
const handleWebmFile: WorkflowFileHandler = async (file) => {
const webmInfo = await getFromWebmFile(file)
return {
workflow: webmInfo.workflow,
prompt: webmInfo.prompt
}
}
/**
* Handler for MP4/MOV/M4V files
*/
const handleMp4File: WorkflowFileHandler = async (file) => {
const mp4Info = await getFromIsobmffFile(file)
return {
workflow: mp4Info.workflow,
prompt: mp4Info.prompt
}
}
/**
* Handler for GLB files
*/
const handleGlbFile: WorkflowFileHandler = async (file) => {
const gltfInfo = await getGltfBinaryMetadata(file)
return {
workflow: gltfInfo.workflow,
prompt: gltfInfo.prompt
}
}
/**
* Handler for JSON files
*/
const handleJsonFile: WorkflowFileHandler = async (file) => {
// For JSON files, we need to preserve the exact behavior from app.ts
// This code intentionally mirrors the original implementation
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
try {
const readerResult = reader.result as string
const jsonContent = JSON.parse(readerResult)
if (jsonContent?.templates) {
// This case will be handled separately in handleFile
resolve({
workflow: undefined,
prompt: undefined,
jsonTemplateData: jsonContent
})
} else if (isApiJson(jsonContent)) {
// API JSON format
resolve({ workflow: undefined, prompt: jsonContent })
} else {
// Regular workflow JSON
resolve({ workflow: JSON.parse(readerResult), prompt: undefined })
}
} catch (error) {
reject(error)
}
}
reader.onerror = () => reject(reader.error)
reader.readAsText(file)
})
}
/**
* Handler for .latent and .safetensors files
*/
const handleLatentFile: WorkflowFileHandler = async (file) => {
// Preserve the exact behavior from app.ts for latent files
const info = await getLatentMetadata(file)
// Direct port of the original code, preserving behavior for TS compatibility
if (info && typeof info === 'object' && 'workflow' in info && info.workflow) {
return {
workflow: JSON.parse(info.workflow as string),
prompt: undefined
}
} else if (
info &&
typeof info === 'object' &&
'prompt' in info &&
info.prompt
) {
return {
workflow: undefined,
prompt: JSON.parse(info.prompt as string)
}
} else {
return { workflow: undefined, prompt: undefined }
}
}
/**
* Helper function to determine if a JSON object is in the API JSON format
*/
function isApiJson(data: unknown) {
return (
typeof data === 'object' &&
data !== null &&
Object.values(data as Record<string, any>).every((v) => v.class_type)
)
}
// Register image format handlers
IMAGE_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'image/png') {
mimeTypeHandlers.set(mimeType, handlePngFile)
} else if (mimeType === 'image/webp') {
mimeTypeHandlers.set(mimeType, handleWebpFile)
} else if (mimeType === 'image/svg+xml') {
mimeTypeHandlers.set(mimeType, handleSvgFile)
}
})
IMAGE_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.png') {
extensionHandlers.set(ext, handlePngFile)
} else if (ext === '.webp') {
extensionHandlers.set(ext, handleWebpFile)
} else if (ext === '.svg') {
extensionHandlers.set(ext, handleSvgFile)
}
})
// Register audio format handlers
AUDIO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'audio/mpeg') {
mimeTypeHandlers.set(mimeType, handleMp3File)
} else if (mimeType === 'audio/ogg') {
mimeTypeHandlers.set(mimeType, handleOggFile)
} else if (mimeType === 'audio/flac' || mimeType === 'audio/x-flac') {
mimeTypeHandlers.set(mimeType, handleFlacFile)
}
})
AUDIO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.mp3') {
extensionHandlers.set(ext, handleMp3File)
} else if (ext === '.ogg') {
extensionHandlers.set(ext, handleOggFile)
} else if (ext === '.flac') {
extensionHandlers.set(ext, handleFlacFile)
}
})
// Register video format handlers
VIDEO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'video/webm') {
mimeTypeHandlers.set(mimeType, handleWebmFile)
} else if (
mimeType === 'video/mp4' ||
mimeType === 'video/quicktime' ||
mimeType === 'video/x-m4v'
) {
mimeTypeHandlers.set(mimeType, handleMp4File)
}
})
VIDEO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.webm') {
extensionHandlers.set(ext, handleWebmFile)
} else if (ext === '.mp4' || ext === '.mov' || ext === '.m4v') {
extensionHandlers.set(ext, handleMp4File)
}
})
// Register 3D model format handlers
MODEL_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'model/gltf-binary') {
mimeTypeHandlers.set(mimeType, handleGlbFile)
}
})
MODEL_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.glb') {
extensionHandlers.set(ext, handleGlbFile)
}
})
// Register data format handlers
DATA_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'application/json') {
mimeTypeHandlers.set(mimeType, handleJsonFile)
}
})
DATA_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.json') {
extensionHandlers.set(ext, handleJsonFile)
} else if (ext === '.latent' || ext === '.safetensors') {
extensionHandlers.set(ext, handleLatentFile)
}
})
/**
* Gets the appropriate file handler for a given file based on mime type or extension
*/
export function getFileHandler(file: File): WorkflowFileHandler | null {
// First try to match by MIME type
if (file.type && mimeTypeHandlers.has(file.type)) {
return mimeTypeHandlers.get(file.type) || null
}
// If no MIME type match, try to match by file extension
if (file.name) {
const extension = '.' + file.name.split('.').pop()?.toLowerCase()
if (extension && extensionHandlers.has(extension)) {
return extensionHandlers.get(extension) || null
}
}
return null
}

View File

@@ -1,53 +0,0 @@
import DOMPurify from 'dompurify'
import { Renderer, marked } from 'marked'
const ALLOWED_TAGS = ['video', 'source']
const ALLOWED_ATTRS = [
'controls',
'autoplay',
'loop',
'muted',
'preload',
'poster'
]
// Matches relative src attributes in img, source, and video HTML tags
// Captures: 1) opening tag with src=", 2) relative path, 3) closing quote
// Excludes absolute paths (starting with /) and URLs (http:// or https://)
const MEDIA_SRC_REGEX =
/(<(?:img|source|video)[^>]*\ssrc=['"])(?!(?:\/|https?:\/\/))([^'"\s>]+)(['"])/gi
// Create a marked Renderer that prefixes relative URLs with base
export function createMarkdownRenderer(baseUrl?: string): Renderer {
const normalizedBase = baseUrl ? baseUrl.replace(/\/+$/, '') : ''
const renderer = new Renderer()
renderer.image = ({ href, title, text }) => {
let src = href
if (normalizedBase && !/^(?:\/|https?:\/\/)/.test(href)) {
src = `${normalizedBase}/${href}`
}
const titleAttr = title ? ` title="${title}"` : ''
return `<img src="${src}" alt="${text}"${titleAttr} />`
}
return renderer
}
export function renderMarkdownToHtml(
markdown: string,
baseUrl?: string
): string {
if (!markdown) return ''
let html = marked.parse(markdown, {
renderer: createMarkdownRenderer(baseUrl)
}) as string
if (baseUrl) {
html = html.replace(MEDIA_SRC_REGEX, `$1${baseUrl}$2$3`)
}
return DOMPurify.sanitize(html, {
ADD_TAGS: ALLOWED_TAGS,
ADD_ATTR: ALLOWED_ATTRS
})
}

View File

@@ -1,51 +0,0 @@
import type { ModelFile } from '@/schemas/comfyWorkflowSchema'
/**
* Gets models from the node's `properties.models` field, excluding those
* not currently selected in at least 1 of the node's widget values.
*
* @example
* ```ts
* const node = {
* type: 'CheckpointLoaderSimple',
* widgets_values: ['model1', 'model2'],
* properties: { models: [{ name: 'model1' }, { name: 'model2' }, { name: 'model3' }] }
* ... other properties
* }
* const selectedModels = getSelectedModelsMetadata(node)
* // selectedModels = [{ name: 'model1' }, { name: 'model2' }]
* ```
*
* @param node - The workflow node to process
* @returns Filtered array containing only models that are currently selected
*/
export function getSelectedModelsMetadata(node: {
type: string
widgets_values?: unknown[] | Record<string, unknown>
properties?: { models?: ModelFile[] }
}): ModelFile[] | undefined {
try {
if (!node.properties?.models?.length) return
if (!node.widgets_values) return
const widgetValues = Array.isArray(node.widgets_values)
? node.widgets_values
: Object.values(node.widgets_values)
if (!widgetValues.length) return
const stringWidgetValues = new Set<string>()
for (const widgetValue of widgetValues) {
if (typeof widgetValue === 'string' && widgetValue.trim()) {
stringWidgetValues.add(widgetValue)
}
}
// Return the node's models that are present in the widget values
return node.properties.models.filter((model) =>
stringWidgetValues.has(model.name)
)
} catch (error) {
console.error('Error filtering models by current selection:', error)
}
}

View File

@@ -1,23 +0,0 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
export function extractCustomNodeName(
pythonModule: string | undefined
): string | null {
const modules = pythonModule?.split('.') || []
if (modules.length >= 2 && modules[0] === 'custom_nodes') {
return modules[1].split('@')[0]
}
return null
}
export function getNodeHelpBaseUrl(node: ComfyNodeDefImpl): string {
const nodeSource = getNodeSource(node.python_module)
if (nodeSource.type === NodeSourceType.CustomNodes) {
const customNodeName = extractCustomNodeName(node.python_module)
if (customNodeName) {
return `/extensions/${customNodeName}/docs/`
}
}
return `/docs/${node.name}/`
}

View File

@@ -1,330 +0,0 @@
import { describe, expect, it } from 'vitest'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
describe('nodeOrganizationService', () => {
const createMockNodeDef = (overrides: any = {}) => {
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test/subcategory',
python_module: 'custom_nodes.MyPackage.nodes',
api_node: false,
nodeSource: {
type: NodeSourceType.CustomNodes,
className: 'comfy-custom',
displayText: 'Custom',
badgeText: 'C'
},
...overrides
}
Object.setPrototypeOf(mockNodeDef, ComfyNodeDefImpl.prototype)
return mockNodeDef as ComfyNodeDefImpl
}
describe('getGroupingStrategies', () => {
it('should return all grouping strategies', () => {
const strategies = nodeOrganizationService.getGroupingStrategies()
expect(strategies).toHaveLength(3)
expect(strategies.map((s) => s.id)).toEqual([
'category',
'module',
'source'
])
})
it('should return immutable copy', () => {
const strategies1 = nodeOrganizationService.getGroupingStrategies()
const strategies2 = nodeOrganizationService.getGroupingStrategies()
expect(strategies1).not.toBe(strategies2)
expect(strategies1).toEqual(strategies2)
})
})
describe('getGroupingStrategy', () => {
it('should return strategy by id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('category')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('getSortingStrategies', () => {
it('should return all sorting strategies', () => {
const strategies = nodeOrganizationService.getSortingStrategies()
expect(strategies).toHaveLength(2)
expect(strategies.map((s) => s.id)).toEqual(['original', 'alphabetical'])
})
})
describe('getSortingStrategy', () => {
it('should return strategy by id', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('alphabetical')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getSortingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('organizeNodes', () => {
const mockNodes = [
createMockNodeDef({ name: 'NodeA', display_name: 'Zebra Node' }),
createMockNodeDef({ name: 'NodeB', display_name: 'Apple Node' })
]
it('should organize nodes with default options', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes)
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom grouping', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'module'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom sorting', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'alphabetical'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should throw error for unknown grouping strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'unknown'
})
}).toThrow('Unknown grouping strategy: unknown')
})
it('should throw error for unknown sorting strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'unknown'
})
}).toThrow('Unknown sorting strategy: unknown')
})
})
describe('getGroupingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getGroupingIcon('category')
expect(icon).toBe('pi pi-folder')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getGroupingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('getSortingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getSortingIcon('alphabetical')
expect(icon).toBe('pi pi-sort-alpha-down')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getSortingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('grouping path extraction', () => {
const mockNodeDef = createMockNodeDef()
it('category grouping should use category path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['test', 'subcategory', 'TestNode'])
})
it('module grouping should extract module path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['MyPackage', 'TestNode'])
})
it('source grouping should categorize by source type', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['Custom nodes', 'TestNode'])
})
})
describe('edge cases', () => {
describe('module grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
it('should handle empty python_module', () => {
const nodeDef = createMockNodeDef({ python_module: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle undefined python_module', () => {
const nodeDef = createMockNodeDef({ python_module: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle modules with spaces in the name', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.My Package With Spaces.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['My Package With Spaces', 'TestNode'])
})
it('should handle modules with special characters', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.my-package_v2.0.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['my-package_v2', 'TestNode'])
})
it('should handle deeply nested modules', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.package.subpackage.module.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['package', 'TestNode'])
})
it('should handle core nodes module path', () => {
const nodeDef = createMockNodeDef({ python_module: 'nodes' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['core', 'TestNode'])
})
it('should handle non-standard module paths', () => {
const nodeDef = createMockNodeDef({
python_module: 'some.other.module.path'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['some', 'other', 'module', 'path', 'TestNode'])
})
})
describe('category grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
it('should handle empty category', () => {
const nodeDef = createMockNodeDef({ category: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle undefined category', () => {
const nodeDef = createMockNodeDef({ category: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle category with trailing slash', () => {
const nodeDef = createMockNodeDef({ category: 'test/subcategory/' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', '', 'TestNode'])
})
it('should handle category with multiple consecutive slashes', () => {
const nodeDef = createMockNodeDef({ category: 'test//subcategory' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', '', 'subcategory', 'TestNode'])
})
})
describe('source grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
it('should handle API nodes', () => {
const nodeDef = createMockNodeDef({
api_node: true,
nodeSource: {
type: NodeSourceType.Core,
className: 'comfy-core',
displayText: 'Core',
badgeText: 'C'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['API nodes', 'TestNode'])
})
it('should handle unknown source type', () => {
const nodeDef = createMockNodeDef({
nodeSource: {
type: 'unknown' as any,
className: 'unknown',
displayText: 'Unknown',
badgeText: '?'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['Unknown', 'TestNode'])
})
})
describe('node name edge cases', () => {
it('should handle nodes with special characters in name', () => {
const nodeDef = createMockNodeDef({
name: 'Test/Node:With*Special<Chars>'
})
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual([
'test',
'subcategory',
'Test/Node:With*Special<Chars>'
])
})
it('should handle nodes with very long names', () => {
const longName = 'A'.repeat(100)
const nodeDef = createMockNodeDef({ name: longName })
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', longName])
})
})
})
describe('sorting comparison', () => {
it('original sort should keep order', () => {
const strategy = nodeOrganizationService.getSortingStrategy('original')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBe(0)
})
it('alphabetical sort should compare display names', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBeGreaterThan(0)
expect(strategy?.compare(nodeB, nodeA)).toBeLessThan(0)
})
})
})

View File

@@ -1,399 +0,0 @@
import { flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
vi.mock('@/scripts/api', () => ({
api: {
fileURL: vi.fn((url) => url)
}
}))
vi.mock('@/i18n', () => ({
i18n: {
global: {
locale: {
value: 'en'
}
}
}
}))
vi.mock('@/types/nodeSource', () => ({
NodeSourceType: {
Core: 'core',
CustomNodes: 'custom_nodes'
},
getNodeSource: vi.fn((pythonModule) => {
if (pythonModule?.startsWith('custom_nodes.')) {
return { type: 'custom_nodes' }
}
return { type: 'core' }
})
}))
vi.mock('dompurify', () => ({
default: {
sanitize: vi.fn((html) => html)
}
}))
vi.mock('marked', () => ({
marked: {
parse: vi.fn((markdown, options) => {
if (options?.renderer) {
if (markdown.includes('![')) {
const matches = markdown.match(/!\[(.*?)\]\((.*?)\)/)
if (matches) {
const [, text, href] = matches
return options.renderer.image({ href, text, title: '' })
}
}
}
return `<p>${markdown}</p>`
})
},
Renderer: class Renderer {
image = vi.fn(
({ href, title, text }) =>
`<img src="${href}" alt="${text}"${title ? ` title="${title}"` : ''} />`
)
link = vi.fn(
({ href, title, text }) =>
`<a href="${href}"${title ? ` title="${title}"` : ''}>${text}</a>`
)
}
}))
describe('nodeHelpStore', () => {
// Define a mock node for testing
const mockCoreNode = {
name: 'TestNode',
display_name: 'Test Node',
description: 'A test node',
inputs: {},
outputs: [],
python_module: 'comfy.test_node'
}
const mockCustomNode = {
name: 'CustomNode',
display_name: 'Custom Node',
description: 'A custom node',
inputs: {},
outputs: [],
python_module: 'custom_nodes.test_module.custom@1.0.0'
}
// Mock fetch responses
const mockFetch = vi.fn()
global.fetch = mockFetch
beforeEach(() => {
// Setup Pinia
setActivePinia(createPinia())
mockFetch.mockReset()
})
it('should initialize with empty state', () => {
const nodeHelpStore = useNodeHelpStore()
expect(nodeHelpStore.currentHelpNode).toBeNull()
expect(nodeHelpStore.isHelpOpen).toBe(false)
})
it('should open help for a node', () => {
const nodeHelpStore = useNodeHelpStore()
nodeHelpStore.openHelp(mockCoreNode as any)
expect(nodeHelpStore.currentHelpNode).toStrictEqual(mockCoreNode)
expect(nodeHelpStore.isHelpOpen).toBe(true)
})
it('should close help', () => {
const nodeHelpStore = useNodeHelpStore()
nodeHelpStore.openHelp(mockCoreNode as any)
expect(nodeHelpStore.isHelpOpen).toBe(true)
nodeHelpStore.closeHelp()
expect(nodeHelpStore.currentHelpNode).toBeNull()
expect(nodeHelpStore.isHelpOpen).toBe(false)
})
it('should generate correct baseUrl for core nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
nodeHelpStore.openHelp(mockCoreNode as any)
await nextTick()
expect(nodeHelpStore.baseUrl).toBe(`/docs/${mockCoreNode.name}/`)
})
it('should generate correct baseUrl for custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
nodeHelpStore.openHelp(mockCustomNode as any)
await nextTick()
expect(nodeHelpStore.baseUrl).toBe('/extensions/test_module/docs/')
})
it('should render markdown content correctly', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test Help\nThis is test help content'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'This is test help content'
)
})
it('should handle fetch errors and fall back to description', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: false,
statusText: 'Not Found'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.error).toBe('Not Found')
expect(nodeHelpStore.renderedHelpHtml).toContain(mockCoreNode.description)
})
it('should include alt attribute for images', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '![image](test.jpg)'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="image"')
})
it('should prefix relative video src in custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '<video src="video.mp4"></video>'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="/extensions/test_module/docs/video.mp4"'
)
})
it('should prefix relative video src for core nodes with node-specific base URL', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '<video src="video.mp4"></video>'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/video.mp4"`
)
})
it('should prefix relative source src in custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () =>
'<video><source src="video.mp4" type="video/mp4" /></video>'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="/extensions/test_module/docs/video.mp4"'
)
})
it('should prefix relative source src for core nodes with node-specific base URL', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () =>
'<video><source src="video.webm" type="video/webm" /></video>'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/video.webm"`
)
})
it('should handle loading state', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves
nodeHelpStore.openHelp(mockCoreNode as any)
await nextTick()
expect(nodeHelpStore.isLoading).toBe(true)
})
it('should try fallback URL for custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch
.mockResolvedValueOnce({
ok: false,
statusText: 'Not Found'
})
.mockResolvedValueOnce({
ok: true,
text: async () => '# Fallback content'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenCalledWith(
'/extensions/test_module/docs/CustomNode/en.md'
)
expect(mockFetch).toHaveBeenCalledWith(
'/extensions/test_module/docs/CustomNode.md'
)
})
it('should prefix relative img src in raw HTML for custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="/extensions/test_module/docs/image.png"'
)
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"')
})
it('should prefix relative img src in raw HTML for core nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/image.png"`
)
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"')
})
it('should not prefix absolute img src in raw HTML', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '<img src="/absolute/image.png" alt="Absolute">'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="/absolute/image.png"'
)
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Absolute"')
})
it('should not prefix external img src in raw HTML', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () =>
'<img src="https://example.com/image.png" alt="External">'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="https://example.com/image.png"'
)
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="External"')
})
it('should handle various quote styles in media src attributes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => `# Media Test
Testing quote styles in properly formed HTML:
<video src="video1.mp4" controls></video>
<video src='video2.mp4' controls></video>
<img src="image1.png" alt="Double quotes">
<img src='image2.png' alt='Single quotes'>
<video controls>
<source src="video3.mp4" type="video/mp4">
<source src='video3.webm' type='video/webm'>
</video>
The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.`
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
// Check that all media elements with different quote styles are prefixed correctly
// Double quotes remain as double quotes
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/video1.mp4"`
)
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/image1.png"`
)
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/video3.mp4"`
)
// Single quotes remain as single quotes in the output
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src='/docs/${mockCoreNode.name}/video2.mp4'`
)
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src='/docs/${mockCoreNode.name}/image2.png'`
)
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src='/docs/${mockCoreNode.name}/video3.webm'`
)
})
})

View File

@@ -1,127 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
AUDIO_WORKFLOW_FORMATS,
DATA_WORKFLOW_FORMATS,
IMAGE_WORKFLOW_FORMATS,
MODEL_WORKFLOW_FORMATS,
VIDEO_WORKFLOW_FORMATS
} from '../../../src/constants/supportedWorkflowFormats'
import {
extensionHandlers,
getFileHandler,
mimeTypeHandlers
} from '../../../src/utils/fileHandlers'
describe('fileHandlers', () => {
describe('handler registrations', () => {
it('should register handlers for all image MIME types', () => {
IMAGE_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all image extensions', () => {
IMAGE_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
it('should register handlers for all audio MIME types', () => {
AUDIO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all audio extensions', () => {
AUDIO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
it('should register handlers for all video MIME types', () => {
VIDEO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all video extensions', () => {
VIDEO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
it('should register handlers for all 3D model MIME types', () => {
MODEL_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all 3D model extensions', () => {
MODEL_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
it('should register handlers for all data MIME types', () => {
DATA_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all data extensions', () => {
DATA_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
})
describe('getFileHandler', () => {
it('should return a handler when a file with a recognized MIME type is provided', () => {
const file = new File(['test content'], 'test.png', { type: 'image/png' })
const handler = getFileHandler(file)
expect(handler).not.toBeNull()
expect(handler).toBeTypeOf('function')
})
it('should return a handler when a file with a recognized extension but no MIME type is provided', () => {
// File with empty MIME type but recognizable extension
const file = new File(['test content'], 'test.webp', { type: '' })
const handler = getFileHandler(file)
expect(handler).not.toBeNull()
expect(handler).toBeTypeOf('function')
})
it('should return null when no handler is found for the file type', () => {
const file = new File(['test content'], 'test.txt', {
type: 'text/plain'
})
const handler = getFileHandler(file)
expect(handler).toBeNull()
})
it('should prioritize MIME type over extension when both are present and different', () => {
// A file with a JSON MIME type but SVG extension
const file = new File(['{}'], 'test.svg', { type: 'application/json' })
const handler = getFileHandler(file)
// Make a shadow copy of the handlers for comparison
const jsonHandler = mimeTypeHandlers.get('application/json')
const svgHandler = extensionHandlers.get('.svg')
// The handler should match the JSON handler, not the SVG handler
expect(handler).toBe(jsonHandler)
expect(handler).not.toBe(svgHandler)
})
})
})

View File

@@ -1,248 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
describe('modelMetadataUtil', () => {
describe('filterModelsByCurrentSelection', () => {
it('should filter models to only include those selected in widget values', () => {
const node = {
type: 'CheckpointLoaderSimple',
widgets_values: ['model_a.safetensors'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
},
{
name: 'model_b.safetensors',
url: 'https://example.com/model_b.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('model_a.safetensors')
})
it('should return empty array when no models match selection', () => {
const node = {
type: 'SomeNode',
widgets_values: ['unmatched_model.safetensors'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(0)
})
it('should handle multiple widget values', () => {
const node = {
type: 'SomeNode',
widgets_values: ['model_a.safetensors', 42, 'model_c.ckpt'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
},
{
name: 'model_b.safetensors',
url: 'https://example.com/model_b.safetensors',
directory: 'checkpoints'
},
{
name: 'model_c.ckpt',
url: 'https://example.com/model_c.ckpt',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(2)
expect(result!.map((m) => m.name)).toEqual([
'model_a.safetensors',
'model_c.ckpt'
])
})
it('should ignore non-string widget values', () => {
const node = {
type: 'SomeNode',
widgets_values: [42, true, null, undefined, 'model_a.safetensors'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('model_a.safetensors')
})
it('should ignore empty strings', () => {
const node = {
type: 'SomeNode',
widgets_values: ['', ' ', 'model_a.safetensors'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('model_a.safetensors')
})
it('should return undefined for nodes without model metadata', () => {
const node = {
type: 'SomeNode',
widgets_values: ['model_a.safetensors']
}
const result = getSelectedModelsMetadata(node)
expect(result).toBeUndefined()
})
it('should return undefined for nodes without widgets_values', () => {
const node = {
type: 'SomeNode',
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toBeUndefined()
})
it('should return undefined for nodes with empty widgets_values', () => {
const node = {
type: 'SomeNode',
widgets_values: [],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toBeUndefined()
})
it('should return undefined for nodes with empty models array', () => {
const node = {
type: 'SomeNode',
widgets_values: ['model_a.safetensors'],
properties: {
models: []
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toBeUndefined()
})
it('should handle object widget values', () => {
const node = {
type: 'SomeNode',
widgets_values: {
ckpt_name: 'model_a.safetensors',
seed: 42
},
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
},
{
name: 'model_b.safetensors',
url: 'https://example.com/model_b.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('model_a.safetensors')
})
it('should work end-to-end to filter outdated metadata', () => {
const node = {
type: 'CheckpointLoaderSimple',
widgets_values: ['current_model.safetensors'],
properties: {
models: [
{
name: 'current_model.safetensors',
url: 'https://example.com/current_model.safetensors',
directory: 'checkpoints'
},
{
name: 'old_model.safetensors',
url: 'https://example.com/old_model.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('current_model.safetensors')
})
})
})

View File

@@ -1,18 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
/* Test files should not be compiled */
"noEmit": true,
// "strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"*.ts",
"*.mts",
"*.config.js",
"browser_tests/**/*.ts",
"tests-ui/**/*.ts"
]
}

View File

@@ -8,7 +8,11 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import type { UserConfigExport } from 'vitest/config'
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
import {
addElementVnodeExportPlugin,
comfyAPIPlugin,
generateImportMapPlugin
} from './build/plugins'
dotenv.config()
@@ -52,18 +56,6 @@ export default defineConfig({
target: DEV_SERVER_COMFYUI_URL
},
// Proxy extension assets (images/videos) under /extensions to the ComfyUI backend
'/extensions': {
target: DEV_SERVER_COMFYUI_URL,
changeOrigin: true
},
// Proxy docs markdown from backend
'/docs': {
target: DEV_SERVER_COMFYUI_URL,
changeOrigin: true
},
...(!DISABLE_TEMPLATES_PROXY
? {
'/templates': {
@@ -85,40 +77,11 @@ export default defineConfig({
: [vue()]),
comfyAPIPlugin(IS_DEV),
generateImportMapPlugin([
{
name: 'vue',
pattern: 'vue',
entry: './dist/vue.esm-browser.prod.js'
},
{
name: 'vue-i18n',
pattern: 'vue-i18n',
entry: './dist/vue-i18n.esm-browser.prod.js'
},
{
name: 'primevue',
pattern: /^primevue\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/themes',
pattern: /^@primevue\/themes\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/forms',
pattern: /^@primevue\/forms\/?.*/,
entry: './index.mjs',
recursiveDependence: true,
override: {
'@primeuix/forms': {
entry: ''
}
}
}
{ name: 'vue', pattern: /[\\/]node_modules[\\/]vue[\\/]/ },
{ name: 'primevue', pattern: /[\\/]node_modules[\\/]primevue[\\/]/ },
{ name: 'vue-i18n', pattern: /[\\/]node_modules[\\/]vue-i18n[\\/]/ }
]),
addElementVnodeExportPlugin(),
Icons({
compiler: 'vue3'