mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
83 Commits
bugfix/cha
...
v1.22.0-su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f2788cd5 | ||
|
|
a1ed6ddbf8 | ||
|
|
3d0a3264fe | ||
|
|
ceaf47c40e | ||
|
|
085ed03e46 | ||
|
|
465903a962 | ||
|
|
f3f7eea87c | ||
|
|
38a118da2e | ||
|
|
f76eae3383 | ||
|
|
97765136ed | ||
|
|
ced55b1cfc | ||
|
|
bdae544c4a | ||
|
|
88bb9eea39 | ||
|
|
1bb18b837e | ||
|
|
ab3a1fff63 | ||
|
|
0de0d1645d | ||
|
|
907f1e27cc | ||
|
|
ed80211ae6 | ||
|
|
afd7741927 | ||
|
|
083ef7d779 | ||
|
|
9cc9e20955 | ||
|
|
b2760a345f | ||
|
|
a3cda65a7b | ||
|
|
c646ba8c53 | ||
|
|
2918eb2213 | ||
|
|
38fed2f2c9 | ||
|
|
53a1598459 | ||
|
|
d06728dad3 | ||
|
|
2405fb16bc | ||
|
|
a56b616571 | ||
|
|
5888f673e4 | ||
|
|
9fdcc02d0f | ||
|
|
f862afafb4 | ||
|
|
7f6d0b3c47 | ||
|
|
cae1845d6c | ||
|
|
9541735bef | ||
|
|
3f5113f49d | ||
|
|
a0059b4ba2 | ||
|
|
5d78cec293 | ||
|
|
2a41b90538 | ||
|
|
ecdf89d8d8 | ||
|
|
6d5cbcc8b6 | ||
|
|
024212c735 | ||
|
|
4342fc46ab | ||
|
|
aebc35f4fb | ||
|
|
afb441654f | ||
|
|
03af7516a4 | ||
|
|
27779ce7c5 | ||
|
|
f2bc74f0b7 | ||
|
|
1589f2cce6 | ||
|
|
c686c9c144 | ||
|
|
d7bca4aa28 | ||
|
|
7d4050de05 | ||
|
|
5077dee42b | ||
|
|
03bfa0ae71 | ||
|
|
d1f4341319 | ||
|
|
8c8bb1a3b7 | ||
|
|
05ef25a7a3 | ||
|
|
86aeeb87bb | ||
|
|
f7093f6ce0 | ||
|
|
88817e5bc0 | ||
|
|
3ac8aa248c | ||
|
|
75ab54ee04 | ||
|
|
a5729c9e06 | ||
|
|
d1da3476da | ||
|
|
ac01bff67e | ||
|
|
ec4ced26e7 | ||
|
|
40cfc43c54 | ||
|
|
35a811c5cf | ||
|
|
3d4ac07957 | ||
|
|
54055e7707 | ||
|
|
69f33f322f | ||
|
|
b81c2f7cd2 | ||
|
|
6289ac9182 | ||
|
|
86a7dd05a3 | ||
|
|
dee00edc5f | ||
|
|
afac449f41 | ||
|
|
aca1a2a194 | ||
|
|
4dfe75d68b | ||
|
|
2c37dba143 | ||
|
|
3936454ffd | ||
|
|
30ee669f5c | ||
|
|
811ddd6165 |
43
.claude/commands/add-missing-i18n.md
Normal file
43
.claude/commands/add-missing-i18n.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
57
.claude/commands/verify-visually.md
Normal file
57
.claude/commands/verify-visually.md
Normal file
@@ -0,0 +1,57 @@
|
||||
Your task is to perform visual verification of our recent changes to ensure they display correctly in the browser. This verification is critical for catching visual regressions, layout issues, and ensuring our UI changes render properly for end users.
|
||||
|
||||
<instructions>
|
||||
Follow these steps systematically to verify our changes:
|
||||
|
||||
1. **Server Setup**
|
||||
- Check if the dev server is running on port 5173 using browser navigation or port checking
|
||||
- If not running, start it with `npm run dev` from the root directory
|
||||
- If the server fails to start, provide detailed troubleshooting steps by reading package.json and README.md for accurate instructions
|
||||
- Wait for the server to be fully ready before proceeding
|
||||
|
||||
2. **Visual Testing Process**
|
||||
- Navigate to http://localhost:5173/
|
||||
- For each target page (specified in arguments or recently changed files):
|
||||
* Navigate to the page using direct URL or site navigation
|
||||
* Take a high-quality screenshot
|
||||
* Analyze the screenshot for the specific changes we implemented
|
||||
* Document any visual issues or improvements needed
|
||||
|
||||
3. **Quality Verification**
|
||||
Check each page for:
|
||||
- Content accuracy and completeness
|
||||
- Proper styling and layout alignment
|
||||
- Responsive design elements
|
||||
- Navigation functionality
|
||||
- Image loading and display
|
||||
- Typography and readability
|
||||
- Color scheme consistency
|
||||
- Interactive elements (buttons, links, forms)
|
||||
</instructions>
|
||||
|
||||
<examples>
|
||||
Common issues to watch for:
|
||||
- Broken layouts or overlapping elements
|
||||
- Missing images or broken image links
|
||||
- Inconsistent styling or spacing
|
||||
- Navigation menu problems
|
||||
- Mobile responsiveness issues
|
||||
- Text overflow or truncation
|
||||
- Color contrast problems
|
||||
</examples>
|
||||
|
||||
<reporting>
|
||||
For each page tested, provide:
|
||||
1. Page URL and screenshot
|
||||
2. Confirmation that changes display correctly OR detailed description of issues found
|
||||
3. Any design improvement suggestions
|
||||
4. Overall assessment of visual quality
|
||||
|
||||
If you find issues, be specific about:
|
||||
- Exact location of the problem
|
||||
- Expected vs actual behavior
|
||||
- Severity level (critical, important, minor)
|
||||
- Suggested fix if obvious
|
||||
</reporting>
|
||||
|
||||
Remember: Take your time with each screenshot and analysis. Visual quality directly impacts user experience and our project's professional appearance.
|
||||
17
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
17
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -1,7 +1,9 @@
|
||||
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:
|
||||
@@ -10,8 +12,15 @@ 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.
|
||||
- **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: 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
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
5
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -1,7 +1,8 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature Request]: "
|
||||
labels: ["enhancement"]
|
||||
title: '[Feature Request]: '
|
||||
labels: ['enhancement']
|
||||
type: Feature
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
100
README.md
100
README.md
@@ -641,100 +641,8 @@ See [locales/README.md](src/locales/README.md) for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
> **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.
|
||||
For comprehensive troubleshooting and technical support, please refer to our official documentation:
|
||||
|
||||
> **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.
|
||||
- **[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
|
||||
59
browser_tests/assets/model_metadata_widget_mismatch.json
Normal file
59
browser_tests/assets/model_metadata_widget_mismatch.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -103,7 +103,7 @@ test.describe('Missing models warning', () => {
|
||||
}
|
||||
])
|
||||
}
|
||||
comfyPage.page.route(
|
||||
await comfyPage.page.route(
|
||||
'**/api/experiment/models',
|
||||
(route) => route.fulfill(modelFoldersRes),
|
||||
{ times: 1 }
|
||||
@@ -121,7 +121,7 @@ test.describe('Missing models warning', () => {
|
||||
}
|
||||
])
|
||||
}
|
||||
comfyPage.page.route(
|
||||
await comfyPage.page.route(
|
||||
'**/api/experiment/models/text_encoders',
|
||||
(route) => route.fulfill(clipModelsRes),
|
||||
{ times: 1 }
|
||||
@@ -133,6 +133,18 @@ 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 ({
|
||||
|
||||
556
browser_tests/tests/nodeHelp.spec.ts
Normal file
556
browser_tests/tests/nodeHelp.spec.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
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
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
59
build/plugins/addElementVnodeExportPlugin.ts
Normal file
59
build/plugins/addElementVnodeExportPlugin.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
53
package-lock.json
generated
53
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.21.2",
|
||||
"version": "1.22.0-sub.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.21.2",
|
||||
"version": "1.22.0-sub.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.16.0-sub.7",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -29,12 +29,14 @@
|
||||
"@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",
|
||||
@@ -54,6 +56,7 @@
|
||||
"@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",
|
||||
@@ -788,9 +791,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.16.0-sub.7",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.0-sub.7.tgz",
|
||||
"integrity": "sha512-L1PIuN4JKczGYP7cu5jsgnjw6rWJRfr4753eBv7ufQX9Jd6g0dyrf6u8XaIVBj2Z1EpWy+SNRxT39VwZWu61KQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -3988,6 +3991,16 @@
|
||||
"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",
|
||||
@@ -4115,6 +4128,13 @@
|
||||
"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",
|
||||
@@ -6605,6 +6625,15 @@
|
||||
"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",
|
||||
@@ -10085,6 +10114,18 @@
|
||||
"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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.21.2",
|
||||
"version": "1.22.0-sub.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -34,6 +34,7 @@
|
||||
"@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",
|
||||
@@ -74,7 +75,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.16.0-sub.7",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -91,12 +92,14 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="workflowStore.isSubgraphActive"
|
||||
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
|
||||
>
|
||||
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
|
||||
<Breadcrumb
|
||||
class="bg-transparent"
|
||||
:home="home"
|
||||
@@ -14,28 +11,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
|
||||
const items = computed(() => {
|
||||
if (!workflowStore.subgraphNamePath.length) return []
|
||||
if (!navigationStore.navigationStack.length) return []
|
||||
|
||||
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
|
||||
label: name,
|
||||
command: async () => {
|
||||
const workflow = workflowStore.getWorkflowByPath(name)
|
||||
if (workflow) await workflowService.openWorkflow(workflow)
|
||||
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(subgraph)
|
||||
}
|
||||
}))
|
||||
})
|
||||
@@ -43,7 +42,7 @@ const items = computed(() => {
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
command: async () => {
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
@@ -55,14 +54,17 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
|
||||
event.item.command?.(event)
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
// Escape exits from the current subgraph.
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(
|
||||
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -21,16 +21,14 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const widgetStates = computed(() =>
|
||||
Array.from(domWidgetStore.widgetStates.values())
|
||||
)
|
||||
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
|
||||
|
||||
const updateWidgets = () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
for (const widgetState of domWidgetStore.widgetStates.values()) {
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
const node = widget.node as LGraphNode
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
@@ -39,12 +41,11 @@
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
@@ -84,6 +85,7 @@ import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -334,6 +336,16 @@ onMounted(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<MaskEditorButton />
|
||||
<ConvertToSubgraphButton />
|
||||
<DeleteButton />
|
||||
<RefreshButton />
|
||||
<ExtensionCommandButton
|
||||
@@ -18,6 +19,7 @@
|
||||
:key="command.id"
|
||||
:command="command"
|
||||
/>
|
||||
<HelpButton />
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
@@ -27,9 +29,11 @@ import { computed } from 'vue'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isVisible"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-box"
|
||||
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isVisible = computed(() => {
|
||||
return (
|
||||
canvasStore.groupSelected ||
|
||||
canvasStore.rerouteSelected ||
|
||||
canvasStore.nodeSelected
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isDeletable"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
@@ -13,10 +14,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isDeletable = computed(() =>
|
||||
canvasStore.selectedItems.some((x) => x.removable !== false)
|
||||
)
|
||||
</script>
|
||||
|
||||
49
src/components/graph/selectionToolbox/HelpButton.vue
Normal file
49
src/components/graph/selectionToolbox/HelpButton.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
@@ -25,8 +25,9 @@ const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isSingleImageNode = computed(() => {
|
||||
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
|
||||
return nodes.length === 1 && nodes.some(isImageNode)
|
||||
const { selectedItems } = canvasStore
|
||||
const item = selectedItems[0]
|
||||
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
|
||||
})
|
||||
|
||||
const openMaskEditor = () => {
|
||||
|
||||
@@ -95,12 +95,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
return
|
||||
}
|
||||
|
||||
disconnectOnReset = false
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
})
|
||||
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
if (disconnectOnReset) {
|
||||
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
|
||||
}
|
||||
disconnectOnReset = false
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
|
||||
@@ -1,67 +1,124 @@
|
||||
<template>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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'
|
||||
@@ -73,24 +130,31 @@ 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 { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import {
|
||||
ComfyNodeDefImpl,
|
||||
buildNodeDefTree,
|
||||
useNodeDefStore
|
||||
} from '@/stores/nodeDefStore'
|
||||
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 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)
|
||||
|
||||
@@ -98,13 +162,70 @@ const nodeBookmarkTreeExplorerRef = ref<InstanceType<
|
||||
typeof NodeBookmarkTreeExplorer
|
||||
> | null>(null)
|
||||
const searchFilter = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const alphabeticalSort = ref(false)
|
||||
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 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(() => {
|
||||
const root = filteredRoot.value || nodeDefStore.nodeTree
|
||||
return alphabeticalSort.value ? sortedTree(root, { groupLeaf: true }) : root
|
||||
// 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 renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
@@ -144,12 +265,6 @@ 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([])
|
||||
@@ -175,8 +290,10 @@ const handleSearch = async (query: string) => {
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expandNode(filteredRoot.value)
|
||||
// Expand the search results tree
|
||||
if (filteredNodeDefs.value.length > 0) {
|
||||
expandNode(root.value)
|
||||
}
|
||||
}
|
||||
|
||||
const onAddFilter = async (
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<NodeTreeFolder :node="node" />
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<NodeTreeLeaf :node="node" />
|
||||
<NodeTreeLeaf :node="node" :open-node-help="props.openNodeHelp" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
|
||||
const props = defineProps<{
|
||||
filteredNodeDefs: ComfyNodeDefImpl[]
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
}>()
|
||||
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
|
||||
230
src/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue
Normal file
230
src/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<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>
|
||||
@@ -22,6 +22,15 @@
|
||||
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>
|
||||
|
||||
@@ -54,6 +63,7 @@ import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
}>()
|
||||
|
||||
// Note: node.data should be present for leaf nodes.
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('CompareSliderThumbnail', () => {
|
||||
it('positions slider based on default value', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const divider = wrapper.find('.bg-white\\/30')
|
||||
expect(divider.attributes('style')).toContain('left: 21%')
|
||||
expect(divider.attributes('style')).toContain('left: 50%')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ import { ref, watch } from 'vue'
|
||||
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const SLIDER_START_POSITION = 21
|
||||
const SLIDER_START_POSITION = 50
|
||||
|
||||
const { baseImageSrc, overlayImageSrc, isHovered, isVideo } = defineProps<{
|
||||
baseImageSrc: string
|
||||
|
||||
@@ -50,9 +50,11 @@ 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)
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined),
|
||||
logout: mockLogout
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -100,8 +102,7 @@ describe('CurrentUserPopover', () => {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Divider: true,
|
||||
Button: true
|
||||
Divider: true
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -114,6 +115,18 @@ 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()
|
||||
|
||||
@@ -132,12 +145,30 @@ 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 (second one)
|
||||
// Find all buttons and get the API pricing button (third one now)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const apiPricingButton = buttons[1]
|
||||
const apiPricingButton = buttons[2]
|
||||
|
||||
// Click the API pricing button
|
||||
await apiPricingButton.trigger('click')
|
||||
|
||||
@@ -37,6 +37,18 @@
|
||||
|
||||
<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')"
|
||||
@@ -90,6 +102,11 @@ 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')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute top-0 left-0 w-auto max-w-full">
|
||||
<div class="w-auto max-w-full">
|
||||
<WorkflowTabs />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
@@ -34,6 +34,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
@@ -673,6 +674,30 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
function: async () => {
|
||||
await firebaseAuthActions.logout()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Graph.ConvertToSubgraph',
|
||||
icon: 'pi pi-sitemap',
|
||||
label: 'Convert Selection to Subgraph',
|
||||
versionAdded: '1.20.1',
|
||||
function: () => {
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
|
||||
|
||||
const res = graph.convertToSubgraph(canvas.selectedItems)
|
||||
if (!res) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -173,5 +173,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
key: 'f'
|
||||
},
|
||||
commandId: 'Workspace.ToggleFocusMode'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'e',
|
||||
ctrl: true,
|
||||
shift: true
|
||||
},
|
||||
commandId: 'Comfy.Graph.ConvertToSubgraph'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
|
||||
import { LiteGraph } from '@comfyorg/litegraph'
|
||||
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
@@ -1583,57 +1583,6 @@ export class GroupNodeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
function addConvertToGroupOptions() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addConvertOption(options, index) {
|
||||
const selected = Object.values(app.canvas.selected_nodes ?? {})
|
||||
const disabled =
|
||||
selected.length < 2 ||
|
||||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
|
||||
options.splice(index, null, {
|
||||
content: `Convert to Group Node`,
|
||||
disabled,
|
||||
callback: convertSelectedNodesToGroupNode
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
function addManageOption(options, index) {
|
||||
const groups = app.graph.extra?.groupNodes
|
||||
const disabled = !groups || !Object.keys(groups).length
|
||||
options.splice(index, null, {
|
||||
content: `Manage Group Nodes`,
|
||||
disabled,
|
||||
callback: () => manageGroupNodes()
|
||||
})
|
||||
}
|
||||
|
||||
// Add to canvas
|
||||
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getCanvasMenuOptions.apply(this, arguments)
|
||||
const index = options.findIndex((o) => o?.content === 'Add Group')
|
||||
const insertAt = index === -1 ? options.length - 1 : index + 2
|
||||
addConvertOption(options, insertAt)
|
||||
addManageOption(options, insertAt + 1)
|
||||
return options
|
||||
}
|
||||
|
||||
// Add to nodes
|
||||
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const options = getNodeMenuOptions.apply(this, arguments)
|
||||
if (!GroupNodeHandler.isGroupNode(node)) {
|
||||
const index = options.findIndex((o) => o?.content === 'Properties')
|
||||
const insertAt = index === -1 ? options.length - 1 : index
|
||||
addConvertOption(options, insertAt)
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
|
||||
@@ -1723,9 +1672,6 @@ const ext: ComfyExtension = {
|
||||
}
|
||||
}
|
||||
],
|
||||
setup() {
|
||||
addConvertToGroupOptions()
|
||||
},
|
||||
async beforeConfigureGraph(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
missingNodeTypes: string[]
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Give Feedback"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convert Selection to Subgraph"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Fit Group To Contents"
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"icon": "Icon",
|
||||
"color": "Color",
|
||||
"error": "Error",
|
||||
"help": "Help",
|
||||
"loading": "Loading",
|
||||
"findIssues": "Find Issues",
|
||||
"reportIssue": "Send Report",
|
||||
@@ -416,7 +417,23 @@
|
||||
"openWorkflow": "Open workflow in local file system",
|
||||
"newBlankWorkflow": "Create a new blank workflow",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "Sort Order"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"modelLibrary": "Model Library",
|
||||
"downloads": "Downloads",
|
||||
@@ -794,6 +811,7 @@
|
||||
"Export": "Export",
|
||||
"Export (API)": "Export (API)",
|
||||
"Give Feedback": "Give Feedback",
|
||||
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
|
||||
"Fit Group To Contents": "Fit Group To Contents",
|
||||
"Group Selected Nodes": "Group Selected Nodes",
|
||||
"Convert selected nodes to group node": "Convert selected nodes to group node",
|
||||
@@ -1274,7 +1292,9 @@
|
||||
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
|
||||
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
|
||||
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
|
||||
"nothingSelected": "Nothing selected"
|
||||
"nothingSelected": "Nothing selected",
|
||||
"cannotCreateSubgraph": "Cannot create subgraph",
|
||||
"failedToConvertToSubgraph": "Failed to convert items to subgraph"
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
@@ -1407,5 +1427,13 @@
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Dar retroalimentación"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir selección en subgrafo"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajustar grupo al contenido"
|
||||
},
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"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",
|
||||
@@ -692,6 +693,7 @@
|
||||
"ComfyUI Forum": "Foro de ComfyUI",
|
||||
"ComfyUI Issues": "Problemas de ComfyUI",
|
||||
"Contact Support": "Contactar soporte",
|
||||
"Convert Selection to Subgraph": "Convertir selección en subgrafo",
|
||||
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
|
||||
"Custom Nodes Manager": "Gestor de nodos personalizados",
|
||||
"Delete Selected Items": "Eliminar elementos seleccionados",
|
||||
@@ -836,6 +838,14 @@
|
||||
"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"
|
||||
@@ -1065,7 +1075,23 @@
|
||||
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
|
||||
"nodeLibrary": "Biblioteca de nodos",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "Orden de clasificación"
|
||||
"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"
|
||||
},
|
||||
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
|
||||
"queue": "Cola",
|
||||
@@ -1331,6 +1357,7 @@
|
||||
"title": "Comienza con una Plantilla"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "No se puede crear el subgrafo",
|
||||
"couldNotDetermineFileType": "No se pudo determinar el tipo de archivo",
|
||||
"dropFileError": "No se puede procesar el elemento soltado: {error}",
|
||||
"emptyCanvas": "Lienzo vacío",
|
||||
@@ -1339,6 +1366,7 @@
|
||||
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
|
||||
"failedToAccessBillingPortal": "No se pudo acceder al portal de facturación: {error}",
|
||||
"failedToApplyTexture": "Error al aplicar textura",
|
||||
"failedToConvertToSubgraph": "No se pudo convertir los elementos en subgrafo",
|
||||
"failedToCreateCustomer": "No se pudo crear el cliente: {error}",
|
||||
"failedToDownloadFile": "Error al descargar el archivo",
|
||||
"failedToExportModel": "Error al exportar modelo como {format}",
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Retour d'information"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Convertir la sélection en sous-graphe"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Ajuster le groupe au contenu"
|
||||
},
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"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",
|
||||
@@ -692,6 +693,7 @@
|
||||
"ComfyUI Forum": "Forum ComfyUI",
|
||||
"ComfyUI Issues": "Problèmes de ComfyUI",
|
||||
"Contact Support": "Contacter le support",
|
||||
"Convert Selection to Subgraph": "Convertir la sélection en sous-graphe",
|
||||
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
|
||||
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
|
||||
"Delete Selected Items": "Supprimer les éléments sélectionnés",
|
||||
@@ -836,6 +838,14 @@
|
||||
"video": "vidéo",
|
||||
"video_models": "modèles_vidéo"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "page de documentation",
|
||||
"inputs": "Entrées",
|
||||
"loadError": "Échec du chargement de l’aide : {error}",
|
||||
"moreHelp": "Pour plus d'aide, visitez la",
|
||||
"outputs": "Sorties",
|
||||
"type": "Type"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Entrez le nom",
|
||||
"saveAsTemplate": "Enregistrer comme modèle"
|
||||
@@ -1065,7 +1075,23 @@
|
||||
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
|
||||
"nodeLibrary": "Bibliothèque de nœuds",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "Ordre de tri"
|
||||
"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"
|
||||
},
|
||||
"openWorkflow": "Ouvrir le flux de travail dans le système de fichiers local",
|
||||
"queue": "File d'attente",
|
||||
@@ -1331,6 +1357,7 @@
|
||||
"title": "Commencez avec un modèle"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "Impossible de créer le sous-graphe",
|
||||
"couldNotDetermineFileType": "Impossible de déterminer le type de fichier",
|
||||
"dropFileError": "Impossible de traiter l'élément déposé : {error}",
|
||||
"emptyCanvas": "Toile vide",
|
||||
@@ -1339,6 +1366,7 @@
|
||||
"errorSaveSetting": "Erreur lors de l'enregistrement du paramètre {id}: {err}",
|
||||
"failedToAccessBillingPortal": "Échec de l'accès au portail de facturation : {error}",
|
||||
"failedToApplyTexture": "Échec de l'application de la texture",
|
||||
"failedToConvertToSubgraph": "Échec de la conversion des éléments en sous-graphe",
|
||||
"failedToCreateCustomer": "Échec de la création du client : {error}",
|
||||
"failedToDownloadFile": "Échec du téléchargement du fichier",
|
||||
"failedToExportModel": "Échec de l'exportation du modèle en {format}",
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "フィードバック"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "選択範囲をサブグラフに変換"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "グループを内容に合わせて調整"
|
||||
},
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "問題を見つける",
|
||||
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
|
||||
"goToNode": "ノードに移動",
|
||||
"help": "ヘルプ",
|
||||
"icon": "アイコン",
|
||||
"imageFailedToLoad": "画像の読み込みに失敗しました",
|
||||
"imageUrl": "画像URL",
|
||||
@@ -692,6 +693,7 @@
|
||||
"ComfyUI Forum": "ComfyUI フォーラム",
|
||||
"ComfyUI Issues": "ComfyUIの問題",
|
||||
"Contact Support": "サポートに連絡",
|
||||
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
|
||||
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
|
||||
"Custom Nodes Manager": "カスタムノードマネージャ",
|
||||
"Delete Selected Items": "選択したアイテムを削除",
|
||||
@@ -836,6 +838,14 @@
|
||||
"video": "ビデオ",
|
||||
"video_models": "ビデオモデル"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "ドキュメントページ",
|
||||
"inputs": "入力",
|
||||
"loadError": "ヘルプの読み込みに失敗しました: {error}",
|
||||
"moreHelp": "さらに詳しい情報は、",
|
||||
"outputs": "出力",
|
||||
"type": "タイプ"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "名前を入力",
|
||||
"saveAsTemplate": "テンプレートとして保存"
|
||||
@@ -1065,7 +1075,23 @@
|
||||
"newBlankWorkflow": "新しい空のワークフローを作成",
|
||||
"nodeLibrary": "ノードライブラリ",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "並び順"
|
||||
"groupBy": "グループ化",
|
||||
"groupStrategies": {
|
||||
"category": "カテゴリ",
|
||||
"categoryDesc": "ノードカテゴリでグループ化",
|
||||
"module": "モジュール",
|
||||
"moduleDesc": "モジュールソースでグループ化",
|
||||
"source": "ソース",
|
||||
"sourceDesc": "ソースタイプ(Core、Custom、API)でグループ化"
|
||||
},
|
||||
"resetView": "ビューをデフォルトにリセット",
|
||||
"sortBy": {
|
||||
"alphabetical": "アルファベット順",
|
||||
"alphabeticalDesc": "グループ内でアルファベット順に並び替え",
|
||||
"original": "元の順序",
|
||||
"originalDesc": "元の順序を維持"
|
||||
},
|
||||
"sortMode": "並び替えモード"
|
||||
},
|
||||
"openWorkflow": "ローカルでワークフローを開く",
|
||||
"queue": "キュー",
|
||||
@@ -1331,6 +1357,7 @@
|
||||
"title": "テンプレートを利用して開始"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "サブグラフを作成できません",
|
||||
"couldNotDetermineFileType": "ファイルタイプを判断できませんでした",
|
||||
"dropFileError": "ドロップされたアイテムを処理できません: {error}",
|
||||
"emptyCanvas": "キャンバスが空です",
|
||||
@@ -1339,6 +1366,7 @@
|
||||
"errorSaveSetting": "設定{id}の保存エラー: {err}",
|
||||
"failedToAccessBillingPortal": "請求ポータルへのアクセスに失敗しました: {error}",
|
||||
"failedToApplyTexture": "テクスチャの適用に失敗しました",
|
||||
"failedToConvertToSubgraph": "アイテムをサブグラフに変換できませんでした",
|
||||
"failedToCreateCustomer": "顧客の作成に失敗しました: {error}",
|
||||
"failedToDownloadFile": "ファイルのダウンロードに失敗しました",
|
||||
"failedToExportModel": "{format}としてモデルのエクスポートに失敗しました",
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "피드백"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "선택 영역을 서브그래프로 변환"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "그룹을 내용에 맞게 맞추기"
|
||||
},
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "문제 찾기",
|
||||
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
|
||||
"goToNode": "노드로 이동",
|
||||
"help": "도움말",
|
||||
"icon": "아이콘",
|
||||
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
|
||||
"imageUrl": "이미지 URL",
|
||||
@@ -692,6 +693,7 @@
|
||||
"ComfyUI Forum": "ComfyUI 포럼",
|
||||
"ComfyUI Issues": "ComfyUI 이슈 페이지",
|
||||
"Contact Support": "고객 지원 문의",
|
||||
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
|
||||
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
|
||||
"Custom Nodes Manager": "사용자 정의 노드 관리자",
|
||||
"Delete Selected Items": "선택한 항목 삭제",
|
||||
@@ -836,6 +838,14 @@
|
||||
"video": "비디오",
|
||||
"video_models": "비디오 모델"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "문서 페이지",
|
||||
"inputs": "입력",
|
||||
"loadError": "도움말을 불러오지 못했습니다: {error}",
|
||||
"moreHelp": "더 많은 도움말은",
|
||||
"outputs": "출력",
|
||||
"type": "유형"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "이름 입력",
|
||||
"saveAsTemplate": "템플릿으로 저장"
|
||||
@@ -1065,7 +1075,23 @@
|
||||
"newBlankWorkflow": "새 빈 워크플로 만들기",
|
||||
"nodeLibrary": "노드 라이브러리",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "정렬 순서"
|
||||
"groupBy": "그룹 기준",
|
||||
"groupStrategies": {
|
||||
"category": "카테고리",
|
||||
"categoryDesc": "노드 카테고리별로 그룹화",
|
||||
"module": "모듈",
|
||||
"moduleDesc": "모듈 소스별로 그룹화",
|
||||
"source": "소스",
|
||||
"sourceDesc": "소스 유형(Core, Custom, API)별로 그룹화"
|
||||
},
|
||||
"resetView": "기본 보기로 재설정",
|
||||
"sortBy": {
|
||||
"alphabetical": "알파벳순",
|
||||
"alphabeticalDesc": "그룹 내에서 알파벳순으로 정렬",
|
||||
"original": "원본 순서",
|
||||
"originalDesc": "원래 순서를 유지"
|
||||
},
|
||||
"sortMode": "정렬 방식"
|
||||
},
|
||||
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
|
||||
"queue": "실행 대기열",
|
||||
@@ -1331,6 +1357,7 @@
|
||||
"title": "템플릿으로 시작하기"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "서브그래프를 생성할 수 없습니다",
|
||||
"couldNotDetermineFileType": "파일 유형을 결정할 수 없습니다",
|
||||
"dropFileError": "드롭된 항목을 처리할 수 없습니다: {error}",
|
||||
"emptyCanvas": "빈 캔버스",
|
||||
@@ -1339,6 +1366,7 @@
|
||||
"errorSaveSetting": "설정 {id} 저장 오류: {err}",
|
||||
"failedToAccessBillingPortal": "결제 포털에 접근하지 못했습니다: {error}",
|
||||
"failedToApplyTexture": "텍스처 적용에 실패했습니다",
|
||||
"failedToConvertToSubgraph": "항목을 서브그래프로 변환하지 못했습니다",
|
||||
"failedToCreateCustomer": "고객 생성에 실패했습니다: {error}",
|
||||
"failedToDownloadFile": "파일 다운로드에 실패했습니다",
|
||||
"failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다",
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "Обратная связь"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "Преобразовать выделенное в подграф"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "Подогнать группу к содержимому"
|
||||
},
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "Найти проблемы",
|
||||
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
|
||||
"goToNode": "Перейти к ноде",
|
||||
"help": "Помощь",
|
||||
"icon": "Иконка",
|
||||
"imageFailedToLoad": "Не удалось загрузить изображение",
|
||||
"imageUrl": "URL изображения",
|
||||
@@ -692,6 +693,7 @@
|
||||
"ComfyUI Forum": "Форум ComfyUI",
|
||||
"ComfyUI Issues": "Проблемы ComfyUI",
|
||||
"Contact Support": "Связаться с поддержкой",
|
||||
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
|
||||
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
|
||||
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
|
||||
"Delete Selected Items": "Удалить выбранные элементы",
|
||||
@@ -836,6 +838,14 @@
|
||||
"video": "видео",
|
||||
"video_models": "видеомодели"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "страницу документации",
|
||||
"inputs": "Входы",
|
||||
"loadError": "Не удалось загрузить справку: {error}",
|
||||
"moreHelp": "Для получения дополнительной помощи посетите",
|
||||
"outputs": "Выходы",
|
||||
"type": "Тип"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Введите название",
|
||||
"saveAsTemplate": "Сохранить как шаблон"
|
||||
@@ -1065,7 +1075,23 @@
|
||||
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
|
||||
"nodeLibrary": "Библиотека нод",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "Порядок сортировки"
|
||||
"groupBy": "Группировать по",
|
||||
"groupStrategies": {
|
||||
"category": "Категория",
|
||||
"categoryDesc": "Группировать по категории узла",
|
||||
"module": "Модуль",
|
||||
"moduleDesc": "Группировать по источнику модуля",
|
||||
"source": "Источник",
|
||||
"sourceDesc": "Группировать по типу источника (Core, Custom, API)"
|
||||
},
|
||||
"resetView": "Сбросить вид по умолчанию",
|
||||
"sortBy": {
|
||||
"alphabetical": "По алфавиту",
|
||||
"alphabeticalDesc": "Сортировать по алфавиту внутри групп",
|
||||
"original": "Оригинальный порядок",
|
||||
"originalDesc": "Сохранять исходный порядок"
|
||||
},
|
||||
"sortMode": "Режим сортировки"
|
||||
},
|
||||
"openWorkflow": "Открыть рабочий процесс в локальной файловой системе",
|
||||
"queue": "Очередь",
|
||||
@@ -1331,6 +1357,7 @@
|
||||
"title": "Начните с шаблона"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "Невозможно создать подграф",
|
||||
"couldNotDetermineFileType": "Не удалось определить тип файла",
|
||||
"dropFileError": "Не удалось обработать перетаскиваемый элемент: {error}",
|
||||
"emptyCanvas": "Пустой холст",
|
||||
@@ -1339,6 +1366,7 @@
|
||||
"errorSaveSetting": "Ошибка сохранения настройки {id}: {err}",
|
||||
"failedToAccessBillingPortal": "Не удалось получить доступ к биллинговому порталу: {error}",
|
||||
"failedToApplyTexture": "Не удалось применить текстуру",
|
||||
"failedToConvertToSubgraph": "Не удалось преобразовать элементы в подграф",
|
||||
"failedToCreateCustomer": "Не удалось создать клиента: {error}",
|
||||
"failedToDownloadFile": "Не удалось скачать файл",
|
||||
"failedToExportModel": "Не удалось экспортировать модель как {format}",
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
"Comfy_Feedback": {
|
||||
"label": "反馈"
|
||||
},
|
||||
"Comfy_Graph_ConvertToSubgraph": {
|
||||
"label": "将选区转换为子图"
|
||||
},
|
||||
"Comfy_Graph_FitGroupToContents": {
|
||||
"label": "适应节点框到内容"
|
||||
},
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "查找问题",
|
||||
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
|
||||
"goToNode": "转到节点",
|
||||
"help": "帮助",
|
||||
"icon": "图标",
|
||||
"imageFailedToLoad": "图像加载失败",
|
||||
"imageUrl": "图片网址",
|
||||
@@ -692,6 +693,7 @@
|
||||
"ComfyUI Forum": "ComfyUI 论坛",
|
||||
"ComfyUI Issues": "ComfyUI 问题",
|
||||
"Contact Support": "联系支持",
|
||||
"Convert Selection to Subgraph": "将选中内容转换为子图",
|
||||
"Convert selected nodes to group node": "将选中节点转换为组节点",
|
||||
"Custom Nodes Manager": "自定义节点管理器",
|
||||
"Delete Selected Items": "删除选定的项目",
|
||||
@@ -836,6 +838,14 @@
|
||||
"video": "视频",
|
||||
"video_models": "视频模型"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "文档页面",
|
||||
"inputs": "输入",
|
||||
"loadError": "加载帮助失败:{error}",
|
||||
"moreHelp": "如需更多帮助,请访问",
|
||||
"outputs": "输出",
|
||||
"type": "类型"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "输入名称",
|
||||
"saveAsTemplate": "另存为模板"
|
||||
@@ -1065,7 +1075,23 @@
|
||||
"newBlankWorkflow": "创建空白工作流",
|
||||
"nodeLibrary": "节点库",
|
||||
"nodeLibraryTab": {
|
||||
"sortOrder": "排序顺序"
|
||||
"groupBy": "分组方式",
|
||||
"groupStrategies": {
|
||||
"category": "类别",
|
||||
"categoryDesc": "按节点类别分组",
|
||||
"module": "模块",
|
||||
"moduleDesc": "按模块来源分组",
|
||||
"source": "来源",
|
||||
"sourceDesc": "按来源类型分组(核心,自定义,API)"
|
||||
},
|
||||
"resetView": "重置视图为默认",
|
||||
"sortBy": {
|
||||
"alphabetical": "字母顺序",
|
||||
"alphabeticalDesc": "在分组内按字母顺序排序",
|
||||
"original": "原始顺序",
|
||||
"originalDesc": "保持原始顺序"
|
||||
},
|
||||
"sortMode": "排序模式"
|
||||
},
|
||||
"openWorkflow": "在本地文件系统中打开工作流",
|
||||
"queue": "队列",
|
||||
@@ -1331,6 +1357,7 @@
|
||||
"title": "从模板开始"
|
||||
},
|
||||
"toastMessages": {
|
||||
"cannotCreateSubgraph": "无法创建子图",
|
||||
"couldNotDetermineFileType": "无法确定文件类型",
|
||||
"dropFileError": "无法处理掉落的项目:{error}",
|
||||
"emptyCanvas": "画布为空",
|
||||
@@ -1339,6 +1366,7 @@
|
||||
"errorSaveSetting": "保存设置 {id} 出错:{err}",
|
||||
"failedToAccessBillingPortal": "访问账单门户失败:{error}",
|
||||
"failedToApplyTexture": "应用纹理失败",
|
||||
"failedToConvertToSubgraph": "无法将项目转换为子图",
|
||||
"failedToCreateCustomer": "创建客户失败:{error}",
|
||||
"failedToDownloadFile": "文件下载失败",
|
||||
"failedToExportModel": "无法将模型导出为 {format}",
|
||||
|
||||
@@ -41,10 +41,10 @@ const zModelFile = z.object({
|
||||
|
||||
const zGraphState = z
|
||||
.object({
|
||||
lastGroupid: z.number().optional(),
|
||||
lastNodeId: z.number().optional(),
|
||||
lastLinkId: z.number().optional(),
|
||||
lastRerouteId: z.number().optional()
|
||||
lastGroupId: z.number(),
|
||||
lastNodeId: z.number(),
|
||||
lastLinkId: z.number(),
|
||||
lastRerouteId: z.number()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -214,6 +214,32 @@ const zComfyNode = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zSubgraphIO = zNodeInput.extend({
|
||||
/** Slot ID (internal; never changes once instantiated). */
|
||||
id: z.string().uuid(),
|
||||
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
|
||||
type: z.string(),
|
||||
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
|
||||
linkIds: z.array(z.number()).optional()
|
||||
})
|
||||
|
||||
const zSubgraphInstance = z
|
||||
.object({
|
||||
id: zNodeId,
|
||||
type: z.string().uuid(),
|
||||
pos: zVector2,
|
||||
size: zVector2,
|
||||
flags: zFlags,
|
||||
order: z.number(),
|
||||
mode: z.number(),
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
widgets_values: zWidgetValues.optional(),
|
||||
color: z.string().optional(),
|
||||
bgcolor: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zGroup = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
@@ -248,9 +274,22 @@ const zExtra = z
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zGraphDefinitions = z.object({
|
||||
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
|
||||
})
|
||||
|
||||
export const zBaseExportableGraph = z.object({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
config: zConfig.optional().nullable(),
|
||||
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
|
||||
subgraphs: z.array(zSubgraphInstance).optional()
|
||||
})
|
||||
|
||||
/** Schema version 0.4 */
|
||||
export const zComfyWorkflow = z
|
||||
.object({
|
||||
export const zComfyWorkflow = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
last_node_id: zNodeId,
|
||||
@@ -262,13 +301,47 @@ export const zComfyWorkflow = z
|
||||
config: zConfig.optional().nullable(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
version: z.number(),
|
||||
models: z.array(zModelFile).optional()
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: zGraphDefinitions.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
/** Required for recursive definition of subgraphs. */
|
||||
interface ComfyWorkflow1BaseType {
|
||||
id?: string
|
||||
revision?: number
|
||||
version: 1
|
||||
models?: z.infer<typeof zModelFile>[]
|
||||
state: z.infer<typeof zGraphState>
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.input<typeof zGroup>[]
|
||||
nodes: z.input<typeof zComfyNode>[]
|
||||
links?: z.input<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.input<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.input<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseInput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Required for recursive definition of subgraphs w/ZodEffects. */
|
||||
interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
|
||||
groups?: z.output<typeof zGroup>[]
|
||||
nodes: z.output<typeof zComfyNode>[]
|
||||
links?: z.output<typeof zComfyLinkObject>[]
|
||||
floatingLinks?: z.output<typeof zComfyLinkObject>[]
|
||||
reroutes?: z.output<typeof zReroute>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Schema version 1 */
|
||||
export const zComfyWorkflow1 = z
|
||||
.object({
|
||||
export const zComfyWorkflow1 = zBaseExportableGraph
|
||||
.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
revision: z.number().optional(),
|
||||
version: z.literal(1),
|
||||
@@ -280,7 +353,96 @@ export const zComfyWorkflow1 = z
|
||||
floatingLinks: z.array(zComfyLinkObject).optional(),
|
||||
reroutes: z.array(zReroute).optional(),
|
||||
extra: zExtra.optional().nullable(),
|
||||
models: z.array(zModelFile).optional()
|
||||
models: z.array(zModelFile).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => z.array(zSubgraphDefinition)
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const zExportedSubgraphIONode = z.object({
|
||||
id: zNodeId,
|
||||
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
||||
pinned: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zExposedWidget = z.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
})
|
||||
|
||||
interface SubgraphDefinitionBase<
|
||||
T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput
|
||||
> {
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: string
|
||||
revision: number
|
||||
name: string
|
||||
|
||||
inputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
outputNode: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExportedSubgraphIONode>
|
||||
: z.output<typeof zExportedSubgraphIONode>
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zSubgraphIO>[]
|
||||
: z.output<typeof zSubgraphIO>[]
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets?: T extends ComfyWorkflow1BaseInput
|
||||
? z.input<typeof zExposedWidget>[]
|
||||
: z.output<typeof zExposedWidget>[]
|
||||
definitions?: {
|
||||
subgraphs: SubgraphDefinitionBase<T>[]
|
||||
}
|
||||
}
|
||||
|
||||
/** A subgraph definition `worfklow.definitions.subgraphs` */
|
||||
export const zSubgraphDefinition = zComfyWorkflow1
|
||||
.extend({
|
||||
/** Unique graph ID. Automatically generated if not provided. */
|
||||
id: z.string().uuid(),
|
||||
revision: z.number(),
|
||||
name: z.string(),
|
||||
inputNode: zExportedSubgraphIONode,
|
||||
outputNode: zExportedSubgraphIONode,
|
||||
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
inputs: z.array(zSubgraphIO).optional(),
|
||||
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
|
||||
outputs: z.array(zSubgraphIO).optional(),
|
||||
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
|
||||
widgets: z.array(zExposedWidget).optional(),
|
||||
definitions: z
|
||||
.object({
|
||||
subgraphs: z.lazy(
|
||||
(): z.ZodArray<
|
||||
z.ZodType<
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
|
||||
z.ZodTypeDef,
|
||||
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
|
||||
>,
|
||||
'many'
|
||||
> => zSubgraphDefinition.array()
|
||||
)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ 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(),
|
||||
|
||||
@@ -219,6 +219,7 @@ 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(),
|
||||
@@ -227,7 +228,7 @@ export const zComfyNodeDef = z.object({
|
||||
/**
|
||||
* Whether the node is an API node. Running API nodes requires login to
|
||||
* Comfy Org account.
|
||||
* https://www.comfy.org/faq
|
||||
* https://docs.comfy.org/tutorials/api-nodes/overview
|
||||
*/
|
||||
api_node: z.boolean().optional()
|
||||
})
|
||||
|
||||
@@ -30,18 +30,14 @@ 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'
|
||||
import { useSubgraphService } from '@/services/subgraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
@@ -58,6 +54,7 @@ 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,
|
||||
@@ -67,18 +64,12 @@ 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 {
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata,
|
||||
importA1111
|
||||
} from './pnginfo'
|
||||
import { importA1111 } from './pnginfo'
|
||||
import { $el, ComfyUI } from './ui'
|
||||
import { ComfyAppMenu } from './ui/menu/index'
|
||||
import { clone } from './utils'
|
||||
@@ -714,25 +705,23 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
#addAfterConfigureHandler() {
|
||||
const app = this
|
||||
const onConfigure = app.graph.onConfigure
|
||||
app.graph.onConfigure = function (this: LGraph, ...args) {
|
||||
const { graph } = this
|
||||
const { onConfigure } = graph
|
||||
graph.onConfigure = function (...args) {
|
||||
fixLinkInputSlots(this)
|
||||
|
||||
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
|
||||
for (const node of app.graph.nodes) {
|
||||
for (const node of graph.nodes) {
|
||||
node.onGraphConfigured?.()
|
||||
}
|
||||
|
||||
const r = onConfigure?.apply(this, args)
|
||||
|
||||
// Fire after onConfigure, used by primitives to generate widget using input nodes config
|
||||
for (const node of app.graph.nodes) {
|
||||
for (const node of graph.nodes) {
|
||||
node.onAfterGraphConfigured?.()
|
||||
}
|
||||
|
||||
pruneWidgets(this.nodes)
|
||||
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -764,6 +753,21 @@ export class ComfyApp {
|
||||
|
||||
this.#graph = new LGraph()
|
||||
|
||||
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory
|
||||
this.graph.events.addEventListener('subgraph-created', (e) => {
|
||||
try {
|
||||
const { subgraph, data } = e.detail
|
||||
useSubgraphService().registerNewSubgraph(subgraph, data)
|
||||
} catch (err) {
|
||||
console.error('Failed to register subgraph', err)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: 'Failed to register subgraph',
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.#addAfterConfigureHandler()
|
||||
|
||||
this.canvas = new LGraphCanvas(canvasEl, this.graph)
|
||||
@@ -776,6 +780,30 @@ export class ComfyApp {
|
||||
LiteGraph.alt_drag_do_clone_nodes = true
|
||||
LiteGraph.macGesturesRequireMac = false
|
||||
|
||||
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
|
||||
'litegraph:set-graph',
|
||||
(e) => {
|
||||
// Assertion: Not yet defined in litegraph.
|
||||
const { newGraph } = e.detail
|
||||
|
||||
const nodeSet = new Set(newGraph.nodes)
|
||||
const widgetStore = useDomWidgetStore()
|
||||
|
||||
// Assertions: UnwrapRef
|
||||
for (const { widget } of widgetStore.activeWidgetStates) {
|
||||
if (!nodeSet.has(widget.node)) {
|
||||
widgetStore.deactivateWidget(widget.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const { widget } of widgetStore.inactiveWidgetStates) {
|
||||
if (nodeSet.has(widget.node)) {
|
||||
widgetStore.activateWidget(widget.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
this.graph.start()
|
||||
|
||||
// Ensure the canvas fills the window
|
||||
@@ -1012,6 +1040,7 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
useSubgraphService().loadSubgraphs(graphData)
|
||||
|
||||
const missingNodeTypes: MissingNodeType[] = []
|
||||
const missingModels: ModelFile[] = []
|
||||
@@ -1037,8 +1066,10 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
// Collect models metadata from node
|
||||
if (n.properties?.models?.length)
|
||||
embeddedModels.push(...n.properties.models)
|
||||
const selectedModels = getSelectedModelsMetadata(n)
|
||||
if (selectedModels?.length) {
|
||||
embeddedModels.push(...selectedModels)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge models from the workflow's root-level 'models' field
|
||||
@@ -1207,6 +1238,9 @@ export class ComfyApp {
|
||||
// Allow widgets to run callbacks before a prompt has been queued
|
||||
// e.g. random seed before every gen
|
||||
executeWidgetsCallback(this.graph.nodes, 'beforeQueued')
|
||||
for (const subgraph of this.graph.subgraphs.values()) {
|
||||
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
|
||||
}
|
||||
|
||||
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
|
||||
try {
|
||||
@@ -1249,9 +1283,13 @@ export class ComfyApp {
|
||||
executeWidgetsCallback(
|
||||
p.workflow.nodes
|
||||
.map((n) => this.graph.getNodeById(n.id))
|
||||
.filter((n) => !!n) as LGraphNode[],
|
||||
.filter((n) => !!n),
|
||||
'afterQueued'
|
||||
)
|
||||
for (const subgraph of this.graph.subgraphs.values()) {
|
||||
executeWidgetsCallback(subgraph.nodes, 'afterQueued')
|
||||
}
|
||||
|
||||
this.canvas.draw(true, true)
|
||||
await this.ui.queue.update()
|
||||
}
|
||||
@@ -1281,161 +1319,44 @@ export class ComfyApp {
|
||||
return f.substring(0, p)
|
||||
}
|
||||
const fileName = removeExt(file.name)
|
||||
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`.
|
||||
|
||||
// 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
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
importA1111(this.graph, pngInfo.parameters)
|
||||
importA1111(this.graph, 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)
|
||||
}
|
||||
} 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 {
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error)
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
}
|
||||
@@ -1648,6 +1569,8 @@ export class ComfyApp {
|
||||
const executionStore = useExecutionStore()
|
||||
executionStore.lastNodeErrors = null
|
||||
executionStore.lastExecutionError = null
|
||||
|
||||
useDomWidgetStore().clear()
|
||||
}
|
||||
|
||||
clientPosToCanvasPos(pos: Vector2): Vector2 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import log from 'loglevel'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
import { api } from './api'
|
||||
@@ -37,6 +38,10 @@ export class ChangeTracker {
|
||||
ds?: { scale: number; offset: [number, number] }
|
||||
nodeOutputs?: Record<string, any>
|
||||
|
||||
private subgraphState?: {
|
||||
navigation: string[]
|
||||
}
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The workflow that this change tracker is tracking
|
||||
@@ -67,6 +72,8 @@ export class ChangeTracker {
|
||||
scale: app.canvas.ds.scale,
|
||||
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
|
||||
}
|
||||
const navigation = useSubgraphNavigationStore().exportState()
|
||||
this.subgraphState = navigation.length ? { navigation } : undefined
|
||||
}
|
||||
|
||||
restore() {
|
||||
@@ -77,6 +84,16 @@ export class ChangeTracker {
|
||||
if (this.nodeOutputs) {
|
||||
app.nodeOutputs = this.nodeOutputs
|
||||
}
|
||||
if (this.subgraphState) {
|
||||
const { navigation } = this.subgraphState
|
||||
useSubgraphNavigationStore().restoreState(navigation)
|
||||
|
||||
const activeId = navigation.at(-1)
|
||||
if (activeId) {
|
||||
const subgraph = app.graph.subgraphs.get(activeId)
|
||||
if (subgraph) app.canvas.setGraph(subgraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateModified() {
|
||||
@@ -376,7 +393,14 @@ export class ChangeTracker {
|
||||
return false
|
||||
|
||||
// Compare other properties normally
|
||||
for (const key of ['links', 'floatingLinks', 'reroutes', 'groups']) {
|
||||
for (const key of [
|
||||
'links',
|
||||
'floatingLinks',
|
||||
'reroutes',
|
||||
'groups',
|
||||
'definitions',
|
||||
'subgraphs'
|
||||
]) {
|
||||
if (!_.isEqual(a[key], b[key])) {
|
||||
return false
|
||||
}
|
||||
@@ -392,7 +416,12 @@ export class ChangeTracker {
|
||||
function sortGraphNodes(graph: ComfyWorkflowJSON) {
|
||||
return {
|
||||
links: graph.links,
|
||||
floatingLinks: graph.floatingLinks,
|
||||
reroutes: graph.reroutes,
|
||||
groups: graph.groups,
|
||||
extra: graph.extra,
|
||||
definitions: graph.definitions,
|
||||
subgraphs: graph.subgraphs,
|
||||
nodes: graph.nodes.sort((a, b) => {
|
||||
if (typeof a.id === 'number' && typeof b.id === 'number') {
|
||||
return a.id - b.id
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
export interface BaseDOMWidget<V extends object | string>
|
||||
export interface BaseDOMWidget<V extends object | string = object | string>
|
||||
extends IBaseWidget<V, string, DOMWidgetOptions<V>> {
|
||||
// ICustomWidget properties
|
||||
type: string
|
||||
@@ -330,9 +330,8 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
export const pruneWidgets = (nodes: LGraphNode[]) => {
|
||||
const nodeSet = new Set(nodes)
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
for (const widgetState of domWidgetStore.widgetStates.values()) {
|
||||
const widget = widgetState.widget
|
||||
if (!nodeSet.has(widget.node as LGraphNode)) {
|
||||
for (const { widget } of domWidgetStore.widgetStates.values()) {
|
||||
if (!nodeSet.has(widget.node)) {
|
||||
domWidgetStore.unregisterWidget(widget.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,19 @@ export const useAlgoliaSearchService = (
|
||||
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
|
||||
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
|
||||
} = options
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__, {
|
||||
hosts: [
|
||||
{
|
||||
url: 'search.comfy.org/api/search',
|
||||
accept: 'read',
|
||||
protocol: 'https'
|
||||
}
|
||||
],
|
||||
baseHeaders: {
|
||||
'X-Algolia-Application-Id': __ALGOLIA_APP_ID__,
|
||||
'X-Algolia-API-Key': __ALGOLIA_API_KEY__
|
||||
}
|
||||
})
|
||||
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
||||
maxSize: maxCacheSize
|
||||
})
|
||||
|
||||
@@ -137,6 +137,8 @@ export const useColorPaletteService = () => {
|
||||
'--bg-img',
|
||||
`url('${backgroundImage}') no-repeat center /cover`
|
||||
)
|
||||
} else {
|
||||
rootStyle.removeProperty('--bg-img')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
RenderShape,
|
||||
type Subgraph,
|
||||
SubgraphNode,
|
||||
type Vector2,
|
||||
createBounds
|
||||
} from '@comfyorg/litegraph'
|
||||
import type {
|
||||
ExportedSubgraphInstance,
|
||||
ISerialisableNodeInput,
|
||||
ISerialisableNodeOutput,
|
||||
ISerialisedNode
|
||||
@@ -35,6 +38,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import {
|
||||
isImageNode,
|
||||
@@ -56,6 +60,260 @@ export const useLitegraphService = () => {
|
||||
const widgetStore = useWidgetStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
|
||||
function registerSubgraphNodeDef(
|
||||
nodeDefV1: ComfyNodeDefV1,
|
||||
subgraph: Subgraph,
|
||||
instanceData: ExportedSubgraphInstance
|
||||
) {
|
||||
const node = class ComfyNode extends SubgraphNode {
|
||||
static comfyClass: string
|
||||
static override title: string
|
||||
static override category: string
|
||||
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
|
||||
|
||||
/**
|
||||
* @internal The initial minimum size of the node.
|
||||
*/
|
||||
#initialMinSize = { width: 1, height: 1 }
|
||||
/**
|
||||
* @internal The key for the node definition in the i18n file.
|
||||
*/
|
||||
get #nodeKey(): string {
|
||||
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(app.graph, subgraph, instanceData)
|
||||
|
||||
this.#setupStrokeStyles()
|
||||
this.#addInputs(ComfyNode.nodeData.inputs)
|
||||
this.#addOutputs(ComfyNode.nodeData.outputs)
|
||||
this.#setInitialSize()
|
||||
this.serialize_widgets = true
|
||||
void extensionService.invokeExtensionsAsync('nodeCreated', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Setup stroke styles for the node under various conditions.
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
if (this.id == app.runningNodeId) {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
}
|
||||
}
|
||||
this.strokeStyles['executionError'] = function (this: LGraphNode) {
|
||||
if (app.lastExecutionError?.node_id == this.id) {
|
||||
return { color: '#f0f', lineWidth: 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add input sockets to the node. (No widget)
|
||||
*/
|
||||
#addInputSocket(inputSpec: InputSpec) {
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(
|
||||
inputSpec.widgetType ?? inputSpec.type
|
||||
)
|
||||
if (widgetConstructor && !inputSpec.forceInput) return
|
||||
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add a widget to the node. For both primitive types and custom widgets
|
||||
* (unless `socketless`), an input socket is also added.
|
||||
*/
|
||||
#addInputWidget(inputSpec: InputSpec) {
|
||||
const widgetInputSpec = { ...inputSpec }
|
||||
if (inputSpec.widgetType) {
|
||||
widgetInputSpec.type = inputSpec.widgetType
|
||||
}
|
||||
const inputName = inputSpec.name
|
||||
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
|
||||
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
|
||||
if (!widgetConstructor || inputSpec.forceInput) return
|
||||
|
||||
const {
|
||||
widget,
|
||||
minWidth = 1,
|
||||
minHeight = 1
|
||||
} = widgetConstructor(
|
||||
this,
|
||||
inputName,
|
||||
transformInputSpecV2ToV1(widgetInputSpec),
|
||||
app
|
||||
) ?? {}
|
||||
|
||||
if (widget) {
|
||||
widget.label = st(nameKey, widget.label ?? inputName)
|
||||
widget.options ??= {}
|
||||
Object.assign(widget.options, {
|
||||
advanced: inputSpec.advanced,
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
}
|
||||
|
||||
if (!widget?.options?.socketless) {
|
||||
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
|
||||
this.addInput(inputName, inputSpec.type, {
|
||||
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
|
||||
localized_name: st(nameKey, inputName),
|
||||
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
|
||||
})
|
||||
}
|
||||
|
||||
this.#initialMinSize.width = Math.max(
|
||||
this.#initialMinSize.width,
|
||||
minWidth
|
||||
)
|
||||
this.#initialMinSize.height = Math.max(
|
||||
this.#initialMinSize.height,
|
||||
minHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add inputs to the node.
|
||||
*/
|
||||
#addInputs(inputs: Record<string, InputSpec>) {
|
||||
for (const inputSpec of Object.values(inputs))
|
||||
this.#addInputSocket(inputSpec)
|
||||
for (const inputSpec of Object.values(inputs))
|
||||
this.#addInputWidget(inputSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Add outputs to the node.
|
||||
*/
|
||||
#addOutputs(outputs: OutputSpec[]) {
|
||||
for (const output of outputs) {
|
||||
const { name, type, is_list } = output
|
||||
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
|
||||
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
|
||||
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
|
||||
const outputOptions = {
|
||||
...shapeOptions,
|
||||
// If the output name is different from the output type, use the output name.
|
||||
// e.g.
|
||||
// - type ("INT"); name ("Positive") => translate name
|
||||
// - type ("FLOAT"); name ("FLOAT") => translate type
|
||||
localized_name:
|
||||
type !== name ? st(nameKey, name) : st(typeKey, name)
|
||||
}
|
||||
this.addOutput(name, type, outputOptions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Set the initial size of the node.
|
||||
*/
|
||||
#setInitialSize() {
|
||||
const s = this.computeSize()
|
||||
// Expand the width a little to fit widget values on screen.
|
||||
const pad =
|
||||
this.widgets?.length &&
|
||||
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
|
||||
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
|
||||
s[1] = Math.max(this.#initialMinSize.height, s[1])
|
||||
this.setSize(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
|
||||
* and 'localized_name' information from the original node definition.
|
||||
*/
|
||||
override configure(data: ISerialisedNode): void {
|
||||
const RESERVED_KEYS = ['name', 'type', 'shape', 'localized_name']
|
||||
|
||||
// Note: input name is unique in a node definition, so we can lookup
|
||||
// input by name.
|
||||
const inputByName = new Map<string, ISerialisableNodeInput>(
|
||||
data.inputs?.map((input) => [input.name, input]) ?? []
|
||||
)
|
||||
// Inputs defined by the node definition.
|
||||
const definedInputNames = new Set(
|
||||
this.inputs.map((input) => input.name)
|
||||
)
|
||||
const definedInputs = this.inputs.map((input) => {
|
||||
const inputData = inputByName.get(input.name)
|
||||
return inputData
|
||||
? {
|
||||
...inputData,
|
||||
// Whether the input has associated widget follows the
|
||||
// original node definition.
|
||||
..._.pick(input, RESERVED_KEYS.concat('widget'))
|
||||
}
|
||||
: input
|
||||
})
|
||||
// Extra inputs that potentially dynamically added by custom js logic.
|
||||
const extraInputs = data.inputs?.filter(
|
||||
(input) => !definedInputNames.has(input.name)
|
||||
)
|
||||
data.inputs = [...definedInputs, ...(extraInputs ?? [])]
|
||||
|
||||
// Note: output name is not unique, so we cannot lookup output by name.
|
||||
// Use index instead.
|
||||
data.outputs = _.zip(this.outputs, data.outputs).map(
|
||||
([output, outputData]) => {
|
||||
// If there are extra outputs in the serialised node, use them directly.
|
||||
// There are currently custom nodes that dynamically add outputs via
|
||||
// js logic.
|
||||
if (!output) return outputData as ISerialisableNodeOutput
|
||||
|
||||
return outputData
|
||||
? {
|
||||
...outputData,
|
||||
..._.pick(output, RESERVED_KEYS)
|
||||
}
|
||||
: output
|
||||
}
|
||||
)
|
||||
|
||||
data.widgets_values = migrateWidgetsValues(
|
||||
ComfyNode.nodeData.inputs,
|
||||
this.widgets ?? [],
|
||||
data.widgets_values ?? []
|
||||
)
|
||||
|
||||
super.configure(data)
|
||||
}
|
||||
}
|
||||
|
||||
addNodeContextMenuHandler(node)
|
||||
addDrawBackgroundHandler(node)
|
||||
addNodeKeyHandler(node)
|
||||
// Note: Some extensions expects node.comfyClass to be set in
|
||||
// `beforeRegisterNodeDef`.
|
||||
node.prototype.comfyClass = nodeDefV1.name
|
||||
node.comfyClass = nodeDefV1.name
|
||||
|
||||
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
|
||||
node.nodeData = nodeDef
|
||||
LiteGraph.registerNodeType(subgraph.id, node)
|
||||
// Note: Do not following assignments before `LiteGraph.registerNodeType`
|
||||
// because `registerNodeType` will overwrite the assignments.
|
||||
node.category = nodeDef.category
|
||||
node.title = nodeDef.display_name || nodeDef.name
|
||||
}
|
||||
|
||||
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
|
||||
const node = class ComfyNode extends LGraphNode {
|
||||
static comfyClass: string
|
||||
@@ -622,8 +880,10 @@ export const useLitegraphService = () => {
|
||||
options
|
||||
)
|
||||
|
||||
const graph = useWorkflowStore().activeSubgraph ?? app.graph
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
app.graph.add(node)
|
||||
graph.add(node)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return node
|
||||
}
|
||||
@@ -665,6 +925,7 @@ export const useLitegraphService = () => {
|
||||
|
||||
return {
|
||||
registerNodeDef,
|
||||
registerSubgraphNodeDef,
|
||||
addNodeOnGraph,
|
||||
getCanvasCenter,
|
||||
goToNode,
|
||||
|
||||
59
src/services/nodeHelpService.ts
Normal file
59
src/services/nodeHelpService.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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()
|
||||
159
src/services/nodeOrganizationService.ts
Normal file
159
src/services/nodeOrganizationService.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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()
|
||||
91
src/services/subgraphService.ts
Normal file
91
src/services/subgraphService.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
type ExportedSubgraph,
|
||||
type ExportedSubgraphInstance,
|
||||
type Subgraph
|
||||
} from '@comfyorg/litegraph'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { useLitegraphService } from './litegraphService'
|
||||
|
||||
export const useSubgraphService = () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
/** Loads a single subgraph definition and registers it with the node def store */
|
||||
function registerLitegraphNode(
|
||||
nodeDef: ComfyNodeDefV1,
|
||||
subgraph: Subgraph,
|
||||
exportedSubgraph: ExportedSubgraph
|
||||
) {
|
||||
const instanceData: ExportedSubgraphInstance = {
|
||||
id: -1,
|
||||
type: exportedSubgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
}
|
||||
|
||||
useLitegraphService().registerSubgraphNodeDef(
|
||||
nodeDef,
|
||||
subgraph,
|
||||
instanceData
|
||||
)
|
||||
}
|
||||
|
||||
function createNodeDef(exportedSubgraph: ExportedSubgraph) {
|
||||
const { id, name } = exportedSubgraph
|
||||
|
||||
const nodeDef: ComfyNodeDefV1 = {
|
||||
input: { required: {} },
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_tooltips: [],
|
||||
name: id,
|
||||
display_name: name,
|
||||
description: `Subgraph node for ${name}`,
|
||||
category: 'subgraph',
|
||||
output_node: false,
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
nodeDefStore.addNodeDef(nodeDef)
|
||||
return nodeDef
|
||||
}
|
||||
|
||||
/** Loads all exported subgraph definitions from workflow */
|
||||
function loadSubgraphs(graphData: ComfyWorkflowJSON) {
|
||||
const subgraphs = graphData.definitions?.subgraphs
|
||||
if (!subgraphs) return
|
||||
|
||||
// Assertion: overriding Zod schema
|
||||
for (const subgraphData of subgraphs as ExportedSubgraph[]) {
|
||||
const subgraph =
|
||||
comfyApp.graph.subgraphs.get(subgraphData.id) ??
|
||||
comfyApp.graph.createSubgraph(subgraphData)
|
||||
|
||||
registerNewSubgraph(subgraph, subgraphData)
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a new subgraph (e.g. user converted from nodes) */
|
||||
function registerNewSubgraph(
|
||||
subgraph: Subgraph,
|
||||
exportedSubgraph: ExportedSubgraph
|
||||
) {
|
||||
const nodeDef = createNodeDef(exportedSubgraph)
|
||||
registerLitegraphNode(nodeDef, subgraph, exportedSubgraph)
|
||||
}
|
||||
|
||||
return {
|
||||
loadSubgraphs,
|
||||
registerNewSubgraph
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
@@ -20,6 +21,7 @@ export const useWorkflowService = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const toastStore = useToastStore()
|
||||
const dialogService = useDialogService()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
async function getFilename(defaultName: string): Promise<string | null> {
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
@@ -285,11 +287,8 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const beforeLoadNewGraph = () => {
|
||||
// Use workspaceStore here as it is patched in unit tests.
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.changeTracker.store()
|
||||
}
|
||||
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
|
||||
domWidgetStore.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,8 +344,7 @@ export const useWorkflowService = () => {
|
||||
options: { position?: Vector2 } = {}
|
||||
) => {
|
||||
const loadedWorkflow = await workflow.load()
|
||||
const data = loadedWorkflow.initialState
|
||||
const workflowJSON = data
|
||||
const workflowJSON = toRaw(loadedWorkflow.initialState)
|
||||
const old = localStorage.getItem('litegrapheditor_clipboard')
|
||||
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
|
||||
// serialisation schema.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Stores all DOM widgets that are used in the canvas.
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, markRaw, ref } from 'vue'
|
||||
import { type Raw, computed, markRaw, ref } from 'vue'
|
||||
|
||||
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
@@ -13,11 +13,20 @@ export interface DomWidgetState extends PositionConfig {
|
||||
visible: boolean
|
||||
readonly: boolean
|
||||
zIndex: number
|
||||
/** If the widget belongs to the current graph/subgraph. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
const widgetStates = ref<Map<string, DomWidgetState>>(new Map())
|
||||
|
||||
const activeWidgetStates = computed(() =>
|
||||
[...widgetStates.value.values()].filter((state) => state.active)
|
||||
)
|
||||
const inactiveWidgetStates = computed(() =>
|
||||
[...widgetStates.value.values()].filter((state) => !state.active)
|
||||
)
|
||||
|
||||
// Register a widget with the store
|
||||
const registerWidget = <V extends object | string>(
|
||||
widget: BaseDOMWidget<V>
|
||||
@@ -28,7 +37,8 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
readonly: false,
|
||||
zIndex: 0,
|
||||
pos: [0, 0],
|
||||
size: [0, 0]
|
||||
size: [0, 0],
|
||||
active: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,9 +47,28 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
widgetStates.value.delete(widgetId)
|
||||
}
|
||||
|
||||
const activateWidget = (widgetId: string) => {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (state) state.active = true
|
||||
}
|
||||
|
||||
const deactivateWidget = (widgetId: string) => {
|
||||
const state = widgetStates.value.get(widgetId)
|
||||
if (state) state.active = false
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
widgetStates.value.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
widgetStates,
|
||||
activeWidgetStates,
|
||||
inactiveWidgetStates,
|
||||
registerWidget,
|
||||
unregisterWidget
|
||||
unregisterWidget,
|
||||
activateWidget,
|
||||
deactivateWidget,
|
||||
clear
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Positionable } from '@comfyorg/litegraph/dist/interfaces'
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
|
||||
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
|
||||
|
||||
export const useTitleEditorStore = defineStore('titleEditor', () => {
|
||||
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
|
||||
@@ -31,6 +31,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
|
||||
const nodeSelected = computed(() => selectedItems.value.some(isLGraphNode))
|
||||
const groupSelected = computed(() => selectedItems.value.some(isLGraphGroup))
|
||||
const rerouteSelected = computed(() => selectedItems.value.some(isReroute))
|
||||
|
||||
const getCanvas = () => {
|
||||
if (!canvas.value) throw new Error('getCanvas: canvas is null')
|
||||
@@ -42,6 +43,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
selectedItems,
|
||||
nodeSelected,
|
||||
groupSelected,
|
||||
rerouteSelected,
|
||||
updateSelectedItems,
|
||||
getCanvas
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ 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
|
||||
@@ -118,6 +119,7 @@ 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')
|
||||
@@ -216,10 +218,22 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDefV1> = {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNodeDefTree(nodeDefs: ComfyNodeDefImpl[]): TreeNode {
|
||||
return buildTree(nodeDefs, (nodeDef: ComfyNodeDefImpl) =>
|
||||
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) =>
|
||||
nodeDef.nodePath.split('/')
|
||||
)
|
||||
return buildTree(nodeDefs, pathExtractor || defaultPathExtractor)
|
||||
}
|
||||
|
||||
export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {
|
||||
@@ -292,8 +306,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
}
|
||||
function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null {
|
||||
// Frontend-only nodes don't have nodeDef
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Optional chaining used in index
|
||||
// @ts-expect-error Optional chaining used in index
|
||||
return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null
|
||||
}
|
||||
|
||||
|
||||
87
src/stores/subgraphNavigationStore.ts
Normal file
87
src/stores/subgraphNavigationStore.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Subgraph } from '@comfyorg/litegraph'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, shallowReactive, shallowRef, watch } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { isNonNullish } from '@/utils/typeGuardUtil'
|
||||
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
|
||||
/**
|
||||
* Stores the current subgraph navigation state; a stack representing subgraph
|
||||
* navigation history from the root graph to the subgraph that is currently
|
||||
* open.
|
||||
*/
|
||||
export const useSubgraphNavigationStore = defineStore(
|
||||
'subgraphNavigation',
|
||||
() => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
/** The currently opened subgraph. */
|
||||
const activeSubgraph = shallowRef<Subgraph>()
|
||||
|
||||
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
|
||||
const idStack = shallowReactive<string[]>([])
|
||||
|
||||
/**
|
||||
* A stack representing subgraph navigation history from the root graph to
|
||||
* the current opened subgraph.
|
||||
*/
|
||||
const navigationStack = computed(() =>
|
||||
idStack.map((id) => app.graph.subgraphs.get(id)).filter(isNonNullish)
|
||||
)
|
||||
|
||||
/**
|
||||
* Restore the navigation stack from a list of subgraph IDs.
|
||||
* @param subgraphIds The list of subgraph IDs to restore the navigation stack from.
|
||||
* @see exportState
|
||||
*/
|
||||
const restoreState = (subgraphIds: string[]) => {
|
||||
idStack.length = 0
|
||||
for (const id of subgraphIds) idStack.push(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the navigation stack as a list of subgraph IDs.
|
||||
* @returns The list of subgraph IDs, ending with the currently active subgraph.
|
||||
* @see restoreState
|
||||
*/
|
||||
const exportState = () => [...idStack]
|
||||
|
||||
// Reset on workflow change
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
() => (idStack.length = 0)
|
||||
)
|
||||
|
||||
// Update navigation stack when opened subgraph changes
|
||||
watch(
|
||||
() => workflowStore.activeSubgraph,
|
||||
(subgraph) => {
|
||||
// Navigated back to the root graph
|
||||
if (!subgraph) {
|
||||
idStack.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
const index = idStack.lastIndexOf(subgraph.id)
|
||||
const lastIndex = idStack.length - 1
|
||||
|
||||
if (index === -1) {
|
||||
// Opened a new subgraph
|
||||
idStack.push(subgraph.id)
|
||||
} else if (index !== lastIndex) {
|
||||
// Navigated to a different subgraph
|
||||
idStack.splice(index + 1, lastIndex - index)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
activeSubgraph,
|
||||
navigationStack,
|
||||
restoreState,
|
||||
exportState
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Subgraph } from '@comfyorg/litegraph'
|
||||
import _ from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, watch } from 'vue'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -156,10 +157,9 @@ export interface WorkflowStore {
|
||||
syncWorkflows: (dir?: string) => Promise<void>
|
||||
reorderWorkflows: (from: number, to: number) => void
|
||||
|
||||
/** An ordered list of all parent subgraphs, ending with the current subgraph. */
|
||||
subgraphNamePath: string[]
|
||||
/** `true` if any subgraph is currently being viewed. */
|
||||
isSubgraphActive: boolean
|
||||
activeSubgraph: Subgraph | undefined
|
||||
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
|
||||
updateActiveGraph: () => void
|
||||
}
|
||||
@@ -427,25 +427,19 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** @see WorkflowStore.subgraphNamePath */
|
||||
const subgraphNamePath = ref<string[]>([])
|
||||
/** @see WorkflowStore.isSubgraphActive */
|
||||
const isSubgraphActive = ref(false)
|
||||
|
||||
/** @see WorkflowStore.activeSubgraph */
|
||||
const activeSubgraph = shallowRef<Raw<Subgraph>>()
|
||||
|
||||
/** @see WorkflowStore.updateActiveGraph */
|
||||
const updateActiveGraph = () => {
|
||||
const subgraph = comfyApp.canvas?.subgraph
|
||||
activeSubgraph.value = subgraph ? markRaw(subgraph) : undefined
|
||||
if (!comfyApp.canvas) return
|
||||
|
||||
const { subgraph } = comfyApp.canvas
|
||||
isSubgraphActive.value = isSubgraph(subgraph)
|
||||
|
||||
if (subgraph) {
|
||||
const [, ...pathFromRoot] = subgraph.pathToRootGraph
|
||||
|
||||
subgraphNamePath.value = pathFromRoot.map((graph) => graph.name)
|
||||
} else {
|
||||
subgraphNamePath.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeWorkflow, updateActiveGraph)
|
||||
@@ -473,8 +467,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
getWorkflowByPath,
|
||||
syncWorkflows,
|
||||
|
||||
subgraphNamePath,
|
||||
isSubgraphActive,
|
||||
activeSubgraph,
|
||||
updateActiveGraph
|
||||
}
|
||||
}) satisfies () => WorkflowStore
|
||||
|
||||
69
src/stores/workspace/nodeHelpStore.ts
Normal file
69
src/stores/workspace/nodeHelpStore.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
21
src/types/litegraph-augmentation.d.ts
vendored
21
src/types/litegraph-augmentation.d.ts
vendored
@@ -75,6 +75,22 @@ declare module '@comfyorg/litegraph' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface BaseWidget extends IBaseWidget {}
|
||||
|
||||
/** Actual members required for execution. */
|
||||
type ExecutableLGraphNode = Pick<
|
||||
LGraphNode,
|
||||
| 'id'
|
||||
| 'type'
|
||||
| 'comfyClass'
|
||||
| 'title'
|
||||
| 'mode'
|
||||
| 'inputs'
|
||||
| 'widgets'
|
||||
| 'isVirtualNode'
|
||||
| 'applyToGraph'
|
||||
| 'getInputNode'
|
||||
| 'getInputLink'
|
||||
>
|
||||
|
||||
interface LGraphNode {
|
||||
constructor: LGraphNodeConstructor
|
||||
|
||||
@@ -88,7 +104,10 @@ declare module '@comfyorg/litegraph' {
|
||||
/** @deprecated groupNode */
|
||||
setInnerNodes?(nodes: LGraphNode[]): void
|
||||
/** Originally a group node API. */
|
||||
getInnerNodes?(): LGraphNode[]
|
||||
getInnerNodes?(
|
||||
nodes?: ExecutableLGraphNode[],
|
||||
subgraphs?: WeakSet<LGraphNode>
|
||||
): ExecutableLGraphNode[]
|
||||
/** @deprecated groupNode */
|
||||
convertToNodes?(): LGraphNode[]
|
||||
recreate?(): Promise<LGraphNode>
|
||||
|
||||
44
src/types/nodeOrganizationTypes.ts
Normal file
44
src/types/nodeOrganizationTypes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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
|
||||
}
|
||||
336
src/utils/fileHandlers.ts
Normal file
336
src/utils/fileHandlers.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ColorOption, LGraph } from '@comfyorg/litegraph'
|
||||
import { ColorOption, LGraph, Reroute } from '@comfyorg/litegraph'
|
||||
import { LGraphGroup, LGraphNode, isColorable } from '@comfyorg/litegraph'
|
||||
import type { ISerialisedGraph } from '@comfyorg/litegraph/dist/types/serialisation'
|
||||
import type {
|
||||
@@ -50,6 +50,10 @@ export const isLGraphGroup = (item: unknown): item is LGraphGroup => {
|
||||
return item instanceof LGraphGroup
|
||||
}
|
||||
|
||||
export const isReroute = (item: unknown): item is Reroute => {
|
||||
return item instanceof Reroute
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color option of all canvas items if they are all the same.
|
||||
* @param items - The items to get the color option of.
|
||||
|
||||
53
src/utils/markdownRendererUtil.ts
Normal file
53
src/utils/markdownRendererUtil.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
51
src/utils/modelMetadataUtil.ts
Normal file
51
src/utils/modelMetadataUtil.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
23
src/utils/nodeHelpUtil.ts
Normal file
23
src/utils/nodeHelpUtil.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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}/`
|
||||
}
|
||||
@@ -21,3 +21,9 @@ export const isAbortError = (
|
||||
export const isSubgraph = (
|
||||
item: LGraph | Subgraph | undefined | null
|
||||
): item is Subgraph => item?.isRootGraph === false
|
||||
|
||||
/**
|
||||
* Check if an item is non-nullish.
|
||||
*/
|
||||
export const isNonNullish = <T>(item: T | undefined | null): item is T =>
|
||||
item != null
|
||||
|
||||
330
tests-ui/tests/services/nodeOrganizationService.test.ts
Normal file
330
tests-ui/tests/services/nodeOrganizationService.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
399
tests-ui/tests/store/nodeHelpStore.test.ts
Normal file
399
tests-ui/tests/store/nodeHelpStore.test.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
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 () => ''
|
||||
})
|
||||
|
||||
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'`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -492,7 +492,7 @@ describe('useWorkflowStore', () => {
|
||||
// Assert
|
||||
console.debug(store.isSubgraphActive)
|
||||
expect(store.isSubgraphActive).toBe(false) // Should default to false
|
||||
expect(store.subgraphNamePath).toEqual([]) // Should default to empty
|
||||
expect(store.activeSubgraph).toBeUndefined() // Should default to empty
|
||||
})
|
||||
|
||||
it('should correctly update state when the root graph is active', async () => {
|
||||
@@ -505,7 +505,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Assert: Check store state
|
||||
expect(store.isSubgraphActive).toBe(false)
|
||||
expect(store.subgraphNamePath).toEqual([]) // Path is empty for root graph
|
||||
expect(store.activeSubgraph).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should correctly update state when a subgraph is active', async () => {
|
||||
@@ -527,10 +527,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Assert: Check store state
|
||||
expect(store.isSubgraphActive).toBe(true)
|
||||
expect(store.subgraphNamePath).toEqual([
|
||||
'Level 1 Subgraph',
|
||||
'Level 2 Subgraph'
|
||||
]) // Path excludes the root
|
||||
expect(store.activeSubgraph).toEqual(mockSubgraph)
|
||||
})
|
||||
|
||||
it('should update automatically when activeWorkflow changes', async () => {
|
||||
@@ -548,7 +545,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Verify initial state
|
||||
expect(store.isSubgraphActive).toBe(true)
|
||||
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
|
||||
expect(store.activeSubgraph).toEqual(initialSubgraph)
|
||||
|
||||
// Act: Change the active workflow
|
||||
const workflow2 = store.createTemporary('workflow2.json')
|
||||
@@ -569,7 +566,7 @@ describe('useWorkflowStore', () => {
|
||||
|
||||
// Assert: Check that the state was updated by the watcher based on the *new* canvas state
|
||||
expect(store.isSubgraphActive).toBe(false) // Should reflect the change to undefined subgraph
|
||||
expect(store.subgraphNamePath).toEqual([]) // Path should be empty for root
|
||||
expect(store.activeSubgraph).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
127
tests-ui/tests/utils/fileHandlers.test.ts
Normal file
127
tests-ui/tests/utils/fileHandlers.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
248
tests-ui/utils/modelMetadataUtil.test.ts
Normal file
248
tests-ui/utils/modelMetadataUtil.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,6 +56,18 @@ 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': {
|
||||
@@ -73,40 +89,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'
|
||||
|
||||
Reference in New Issue
Block a user