Compare commits

..

7 Commits

Author SHA1 Message Date
filtered
ead7e190f8 Increment version to 1.21.6 (#4051) 2025-06-02 10:03:00 +10:00
filtered
df2feea227 Revert "Export vue new (#3966)" (#4050) 2025-06-01 16:58:37 -07:00
filtered
5d87cf4f8a Increment version to 1.21.5 (#4049) 2025-06-02 06:33:32 +10:00
filtered
ec26daa020 Revert "[Dev] Add Playwright MCP for Local Development (#4028)" (#4048) 2025-06-01 13:24:57 -07:00
filtered
ca72839af9 Increment version to 1.21.4 (#4046) 2025-06-02 03:30:55 +10:00
filtered
bb400f181a Revert "[refactor] Refactor file handling (#3955)"
This reverts commit 30ee669f5c.
2025-06-01 09:56:45 -07:00
filtered
27dcc19a4b Revert "[fix] Remove dynamic import timing issue causing Playwright test flakiness (#4031)"
This reverts commit afac449f41.
2025-06-01 09:56:31 -07:00
134 changed files with 2376 additions and 23172 deletions

View File

@@ -1,57 +0,0 @@
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.

View File

@@ -1,25 +1,26 @@
# Vue 3 Composition API Project Rules
// Vue 3 Composition API .cursorrules
## Vue 3 Composition API Best Practices
- Use setup() function for component logic
- Utilize ref and reactive for reactive state
- Implement computed properties with computed()
- Use watch and watchEffect for side effects
- Implement lifecycle hooks with onMounted, onUpdated, etc.
- Utilize provide/inject for dependency injection
- Use vue 3.5 style of default prop declaration. Example:
// Vue 3 Composition API best practices
const vue3CompositionApiBestPractices = [
"Use setup() function for component logic",
"Utilize ref and reactive for reactive state",
"Implement computed properties with computed()",
"Use watch and watchEffect for side effects",
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
"Utilize provide/inject for dependency injection",
"Use vue 3.5 style of default prop declaration. Example:
```typescript
const { nodes, showTotal = true } = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
}>()
```
- Organize vue component in <template> <script> <style> order
",
"Organize vue component in <template> <script> <style> order",
]
## Project Structure
```
// Folder structure
const folderStructure = `
src/
components/
constants/
@@ -29,25 +30,16 @@ src/
services/
App.vue
main.ts
```
`;
## Styling Guidelines
- Use Tailwind CSS for styling
- Implement responsive design with Tailwind CSS
// Tailwind CSS best practices
const tailwindCssBestPractices = [
"Use Tailwind CSS for styling",
"Implement responsive design with Tailwind CSS",
]
## PrimeVue Component Guidelines
DO NOT use deprecated PrimeVue components. Use these replacements instead:
- Dropdown → Use Select (import from 'primevue/select')
- OverlayPanel → Use Popover (import from 'primevue/popover')
- Calendar → Use DatePicker (import from 'primevue/datepicker')
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
- Sidebar → Use Drawer (import from 'primevue/drawer')
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
- TabMenu → Use Tabs without panels
- Steps → Use Stepper without panels
- InlineMessage → Use Message component
## Development Guidelines
// Additional instructions
const additionalInstructions = `
1. Leverage VueUse functions for performance-enhancing styles
2. Use lodash for utility functions
3. Use TypeScript for type safety
@@ -57,5 +49,6 @@ DO NOT use deprecated PrimeVue components. Use these replacements instead:
7. Implement proper error handling
8. Follow Vue 3 style guide and naming conventions
9. Use Vite for fast development and building
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
11. Never use deprecated PrimeVue components listed above
10. Use vue-i18n in composition API for any string literals. Place new translation
entries in src/locales/en/main.json.
`;

View File

@@ -29,7 +29,3 @@ DISABLE_TEMPLATES_PROXY=false
# If playwright tests are being run via vite dev server, Vue plugins will
# invalidate screenshots. When `true`, vite plugins will not be loaded.
DISABLE_VUE_PLUGINS=false
# Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579

View File

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

View File

@@ -66,7 +66,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -136,7 +136,7 @@ jobs:
git commit -m "Update locales"
- name: Install SSH key For PUSH
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
uses: shimataro/ssh-key-action@v2
with:
# PR private key from action server
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}

View File

@@ -33,7 +33,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
working-directory: ComfyUI_frontend
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: "Update locales for node definitions"

View File

@@ -54,7 +54,7 @@ jobs:
name: dist-files
- name: Create release
id: create_release
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -93,7 +93,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -30,7 +30,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -29,7 +29,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update litegraph to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -1,92 +0,0 @@
name: Update ComfyUI-Manager API Types
on:
# Manual trigger
workflow_dispatch:
jobs:
update-manager-types:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Checkout ComfyUI-Manager repository
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI-Manager
path: ComfyUI-Manager
clean: true
- name: Get Manager commit information
id: manager-info
run: |
cd ComfyUI-Manager
MANAGER_COMMIT=$(git rev-parse --short HEAD)
echo "commit=${MANAGER_COMMIT}" >> $GITHUB_OUTPUT
cd ..
- name: Generate Manager API types
run: |
echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..."
npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
- name: Validate generated types
run: |
if [ ! -f ./src/types/generatedManagerTypes.ts ]; then
echo "Error: Types file was not generated."
exit 1
fi
# Check if file is not empty
if [ ! -s ./src/types/generatedManagerTypes.ts ]; then
echo "Error: Generated types file is empty."
exit 1
fi
- name: Check for changes
id: check-changes
run: |
if [[ -z $(git status --porcelain ./src/types/generatedManagerTypes.ts) ]]; then
echo "No changes to ComfyUI-Manager API types detected."
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
else
echo "Changes detected in ComfyUI-Manager API types."
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
title: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
body: |
## Automated API Type Update
This PR updates the ComfyUI-Manager API types from the latest ComfyUI-Manager OpenAPI specification.
- Manager commit: ${{ steps.manager-info.outputs.commit }}
- Generated on: ${{ github.event.repository.updated_at }}
These types are automatically generated using openapi-typescript.
branch: update-manager-types-${{ steps.manager-info.outputs.commit }}
base: main
labels: Manager
delete-branch: true
add-paths: |
src/types/generatedManagerTypes.ts

View File

@@ -75,7 +75,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'

View File

@@ -38,7 +38,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Bump version to ${{ steps.bump-version.outputs.NEW_VERSION }}'

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"]
}
}
}

View File

@@ -4,7 +4,7 @@
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
- Never add lines to PR descriptions that say "Generated with Claude Code"
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading speicifc branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
@@ -12,7 +12,7 @@
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- IMPORTANT: Never add Co-Authored by Claude or any refrence to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- The npm script to type check is called "typecheck" NOT "type check"
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
@@ -36,21 +36,3 @@
- Use Vite for fast development and building
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
- DO NOT use deprecated PrimeVue components. Use these replacements instead:
* `Dropdown` → Use `Select` (import from 'primevue/select')
* `OverlayPanel` → Use `Popover` (import from 'primevue/popover')
* `Calendar` → Use `DatePicker` (import from 'primevue/datepicker')
* `InputSwitch` → Use `ToggleSwitch` (import from 'primevue/toggleswitch')
* `Sidebar` → Use `Drawer` (import from 'primevue/drawer')
* `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
* `TabMenu` → Use `Tabs` without panels
* `Steps` → Use `Stepper` without panels
* `InlineMessage` → Use `Message` component
* Use `api.apiURL()` for all backend API calls and routes
- Actual API endpoints like /prompt, /queue, /view, etc.
- Image previews: `api.apiURL('/view?...')`
- Any backend-generated content or dynamic routes
* Use `api.fileURL()` for static files served from the public folder:
- Templates: `api.fileURL('/templates/default.json')`
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
- Any static assets that exist in the public directory

162
README.md
View File

@@ -609,68 +609,6 @@ This project includes `.vscode/launch.json.default` and `.vscode/settings.json.d
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
### Recommended MCP Integrations
#### Playwright Browser Automation
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
##### Setup for Claude Code
After installing dependencies with `npm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
```
> navigate to localhost:5173 and take a screenshot.
tell me the items in the menubar
● I'll navigate to localhost:5173 and take a
screenshot to see the menubar items.
● playwright:playwright_navigate (MCP)(url: …
"http://local
host:5173")
⎿ Navigated to http://localhost:5173
● playwright:playwright_screenshot (MCP)(name: "lo…
alhost_5173
_menubar")
⎿ Screenshot saved to: ../../../Downloads/localho
st_5173_menubar-2025-05-30T17-23-00-942Z.png
⎿ Screenshot also stored in memory with name:
'localhost_5173_menubar'
● Read(../../../Downloads/localhost_5173_menubar-2…
25-05-30T17-23-00-942Z.png)
⎿ Read image (94.3KB)
● Based on the screenshot, I can see the menubar at
the top of the ComfyUI interface contains the
following items:
Main Menubar Items:
- Workflow - Menu for workflow-related actions
- Edit - Menu for editing operations
- Help - Menu for help and documentation
Additional UI Elements:
- ComfyUI Logo - On the far left
- Workflow Tab - Shows "Unsaved Workflow" with a
dropdown and close button
- Layout Controls - On the far right (grid view
and hamburger menu icons)
The interface shows a typical ComfyUI workflow
graph with nodes like "Load Checkpoint", "CLIP
Text Encode (Prompt)", "KSampler", and "Empty
Latent Image" connected with colored cables.
```
### Unit Test
- `npm i` to install all dependencies
@@ -703,8 +641,100 @@ See [locales/README.md](src/locales/README.md) for details.
## Troubleshooting
For comprehensive troubleshooting and technical support, please refer to our official documentation:
> **Note**: For comprehensive troubleshooting and how-to guides, please refer to our [official documentation](https://docs.comfy.org/). This section covers only the most common issues related to frontend development.
- **[General Troubleshooting Guide](https://docs.comfy.org/troubleshooting/overview)** - Common issues, performance optimization, and reporting bugs
- **[Custom Node Issues](https://docs.comfy.org/troubleshooting/custom-node-issues)** - Debugging custom node problems and conflicts
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting
> **Desktop Users**: For issues specific to the desktop application, please refer to the [ComfyUI desktop repository](https://github.com/Comfy-Org/desktop).
### Debugging Custom Node (Extension) Issues
If you're experiencing crashes, errors, or unexpected behavior with ComfyUI, it's often caused by custom nodes (extensions). Follow these steps to identify and resolve the issues:
#### Step 1: Verify if custom nodes are causing the problem
Run ComfyUI with the `--disable-all-custom-nodes` flag:
```bash
python main.py --disable-all-custom-nodes
```
If the issue disappears, a custom node is the culprit. Proceed to the next step.
#### Step 2: Identify the problematic custom node using binary search
Rather than disabling nodes one by one, use this more efficient approach:
1. Temporarily move half of your custom nodes out of the `custom_nodes` directory
```bash
# Create a temporary directory
# Linux/Mac
mkdir ~/custom_nodes_disabled
# Windows
mkdir %USERPROFILE%\custom_nodes_disabled
# Move half of your custom nodes (assuming you have node1 through node8)
# Linux/Mac
mv custom_nodes/node1 custom_nodes/node2 custom_nodes/node3 custom_nodes/node4 ~/custom_nodes_disabled/
# Windows
move custom_nodes\node1 custom_nodes\node2 custom_nodes\node3 custom_nodes\node4 %USERPROFILE%\custom_nodes_disabled\
```
2. Run ComfyUI again
- If the issue persists: The problem is in nodes 5-8 (the remaining half)
- If the issue disappears: The problem is in nodes 1-4 (the moved half)
3. Let's assume the issue disappeared, so the problem is in nodes 1-4. Move half of these for the next test:
```bash
# Move nodes 3-4 back to custom_nodes
# Linux/Mac
mv ~/custom_nodes_disabled/node3 ~/custom_nodes_disabled/node4 custom_nodes/
# Windows
move %USERPROFILE%\custom_nodes_disabled\node3 %USERPROFILE%\custom_nodes_disabled\node4 custom_nodes\
```
4. Run ComfyUI again
- If the issue reappears: The problem is in nodes 3-4
- If issue still gone: The problem is in nodes 1-2
5. Let's assume the issue reappeared, so the problem is in nodes 3-4. Test each one:
```bash
# Move node 3 back to disabled
# Linux/Mac
mv custom_nodes/node3 ~/custom_nodes_disabled/
# Windows
move custom_nodes\node3 %USERPROFILE%\custom_nodes_disabled\
```
6. Run ComfyUI again
- If the issue disappears: node3 is the problem
- If issue persists: node4 is the problem
7. Repeat until you identify the specific problematic node
#### Step 3: Update or replace the problematic node
Once identified:
1. Check for updates to the problematic custom node
2. Consider alternatives with similar functionality
3. Report the issue to the custom node developer with specific details
### Common Issues and Solutions
- **"Module not found" errors**: Usually indicates missing Python dependencies. Check the custom node's `requirements.txt` file for required packages and install them:
```bash
pip install -r custom_nodes/problematic_node/requirements.txt
```
- **Frontend or Templates Package Not Updated**: After updating ComfyUI via Git, ensure you update the frontend dependencies:
```bash
pip install -r requirements.txt
```
- **Can't Find Custom Node**: Make sure to disable node validation in ComfyUI settings.
- **Error Toast About Workflow Failing Validation**: Report the issue to the ComfyUI team. As a temporary workaround, disable workflow validation in settings.
- **Login Issues When Not on Localhost**: Normal login is only available when accessing from localhost. If you're running ComfyUI via LAN, another domain, or headless, you can use our API key feature to authenticate. The API key lets you log in normally through the UI. Generate an API key at [platform.comfy.org/login](https://platform.comfy.org/login) and use it in the API Key field in the login dialog or with the `--api-key` command line argument. Refer to our [API Key Integration Guide](https://docs.comfy.org/essentials/comfyui-server/api-key-integration#integration-of-api-key-to-use-comfyui-api-nodes) for complete setup instructions.

View File

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

View File

@@ -762,7 +762,7 @@ export class ComfyPage {
y: 625
}
})
await this.page.mouse.move(10, 10)
this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -774,7 +774,7 @@ export class ComfyPage {
},
button: 'right'
})
await this.page.mouse.move(10, 10)
this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -1046,8 +1046,6 @@ export class ComfyPage {
}
}
export const testComfySnapToGridGridSize = 50
export const comfyPageFixture = base.extend<{
comfyPage: ComfyPage
comfyMouse: ComfyMouse
@@ -1074,8 +1072,7 @@ export const comfyPageFixture = base.extend<{
'Comfy.EnableTooltips': false,
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize
'Comfy.TutorialCompleted': true
})
} catch (e) {
console.error(e)

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,12 +1,6 @@
import { expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -63,10 +57,8 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
const dragSelectNodes = async (
comfyPage: ComfyPage,
clipNodes: NodeReference[]
) => {
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
@@ -82,67 +74,10 @@ test.describe('Node Interaction', () => {
}
)
await comfyPage.page.keyboard.up('Meta')
}
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
await dragSelectNodes(comfyPage, clipNodes)
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
clipNodes.length
)
})
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const getPositions = () =>
Promise.all(clipNodes.map((node) => node.getPosition()))
const testDirection = async ({
direction,
expectedPosition
}: {
direction: string
expectedPosition: (originalPosition: Position) => Position
}) => {
const originalPositions = await getPositions()
await dragSelectNodes(comfyPage, clipNodes)
await comfyPage.executeCommand(
`Comfy.Canvas.MoveSelectedNodes.${direction}`
)
await comfyPage.canvas.press(`Control+Arrow${direction}`)
const newPositions = await getPositions()
expect(newPositions).toEqual(originalPositions.map(expectedPosition))
}
await testDirection({
direction: 'Down',
expectedPosition: (originalPosition) => ({
...originalPosition,
y: originalPosition.y + testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Right',
expectedPosition: (originalPosition) => ({
...originalPosition,
x: originalPosition.x + testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Up',
expectedPosition: (originalPosition) => ({
...originalPosition,
y: originalPosition.y - testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Left',
expectedPosition: (originalPosition) => ({
...originalPosition,
x: originalPosition.x - testComfySnapToGridGridSize
})
})
})
})
test('Can drag node', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 79 KiB

1899
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.23.0",
"version": "1.21.6",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -29,13 +29,11 @@
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@executeautomation/playwright-mcp-server": "^1.0.5",
"@iconify/json": "^2.2.245",
"@lobehub/i18n-cli": "^1.20.0",
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.52.0",
"@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",
@@ -76,7 +74,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.15",
"@comfyorg/litegraph": "^0.15.14",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -93,14 +91,12 @@
"@xterm/xterm": "^5.5.0",
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",

View File

@@ -1,12 +1,14 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-for="item in dialogStore.dialogStack"
v-for="(item, index) in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:auto-z-index="false"
:pt="item.dialogComponentProps.pt"
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:aria-labelledby="item.key"
>
<template #header>
@@ -33,11 +35,25 @@
</template>
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import { usePrimeVue } from '@primevue/core'
import Dialog from 'primevue/dialog'
import { computed, onMounted } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const primevue = usePrimeVue()
const baseZIndex = computed(() => {
return primevue?.config?.zIndex?.modal ?? 1100
})
onMounted(() => {
const mask = document.createElement('div')
ZIndex.set('model', mask, baseZIndex.value)
})
</script>
<style>

View File

@@ -31,12 +31,6 @@
</template>
</ListBox>
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<PackInstallButton
:disabled="isLoading || !!error || missingNodePacks.length === 0"
:node-packs="missingNodePacks"
variant="black"
:label="$t('manager.installAllMissingNodes')"
/>
<Button label="Open Manager" size="small" outlined @click="openManager" />
</div>
</template>
@@ -47,8 +41,6 @@ import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
@@ -60,9 +52,6 @@ const props = defineProps<{
const aboutPanelStore = useAboutPanelStore()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core

View File

@@ -32,8 +32,6 @@
v-model:sortField="sortField"
:search-results="searchResults"
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
/>
<div class="flex-1 overflow-auto">
<div
@@ -95,14 +93,7 @@
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -115,7 +106,6 @@ import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useManagerStatePersistence } from '@/composables/manager/useManagerStatePersistence'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
@@ -126,15 +116,13 @@ import type { TabItem } from '@/types/comfyManagerTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { initialTab } = defineProps<{
initialTab?: ManagerTab
const { initialTab = ManagerTab.All } = defineProps<{
initialTab: ManagerTab
}>()
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
const GRID_STYLE = {
display: 'grid',
@@ -168,10 +156,8 @@ const tabs = ref<TabItem[]>([
icon: 'pi-sync'
}
])
const initialTabId = initialTab ?? initialState.selectedTabId
const selectedTab = ref<TabItem>(
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
tabs.value.find((tab) => tab.id === initialTab) || tabs.value[0]
)
const {
@@ -181,13 +167,8 @@ const {
searchResults,
searchMode,
sortField,
suggestions,
sortOptions
} = useRegistrySearch({
initialSortField: initialState.sortField,
initialSearchMode: initialState.searchMode,
initialSearchQuery: initialState.searchQuery
})
suggestions
} = useRegistrySearch()
pageNumber.value = 0
const onApproachEnd = () => {
pageNumber.value++
@@ -251,11 +232,7 @@ watch(
if (!isEmptySearch.value) {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
} else if (
!installedPacks.value.length &&
!installedPacksReady.value &&
!isLoadingInstalled.value
) {
} else if (!installedPacks.value.length) {
await startFetchInstalled()
} else {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
@@ -431,36 +408,19 @@ const handleGridContainerClick = (event: MouseEvent) => {
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
// Track the last pack ID for which we've fetched full registry data
const lastFetchedPackId = ref<string | null>(null)
// Whenever a single pack is selected, fetch its full info once
whenever(selectedNodePack, async () => {
// Cancel any in-flight requests from previously selected node pack
getPackById.cancel()
if (!selectedNodePack.value?.id) return
// If only a single node pack is selected, fetch full node pack info from registry
const pack = selectedNodePack.value
if (!pack?.id) return
if (hasMultipleSelections.value) return
// Only fetch if we haven't already for this pack
if (lastFetchedPackId.value === pack.id) return
const data = await getPackById.call(pack.id)
// If selected node hasn't changed since request, merge registry & Algolia data
if (data?.id === pack.id) {
lastFetchedPackId.value = pack.id
const mergedPack = merge({}, pack, data)
// Update the pack in current selection without changing selection state
const packIndex = selectedNodePacks.value.findIndex(
(p) => p.id === mergedPack.id
)
if (packIndex !== -1) {
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
}
// Replace pack in displayPacks so that children receive a fresh prop reference
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
if (idx !== -1) {
displayPacks.value.splice(idx, 1, mergedPack)
}
const data = await getPackById.call(selectedNodePack.value.id)
if (data?.id === selectedNodePack.value?.id) {
// If selected node hasn't changed since request, merge registry & Algolia data
selectedNodePacks.value = [merge(selectedNodePack.value, data)]
}
})
@@ -475,15 +435,6 @@ watch(searchQuery, () => {
}
})
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,
searchQuery: searchQuery.value,
searchMode: searchMode.value,
sortField: sortField.value
})
})
onUnmounted(() => {
getPackById.cancel()
})

View File

@@ -62,7 +62,6 @@ describe('PackVersionBadge', () => {
return mount(PackVersionBadge, {
props: {
nodePack: mockNodePack,
isSelected: false,
...props
},
global: {
@@ -163,58 +162,4 @@ describe('PackVersionBadge', () => {
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
describe('selection state changes', () => {
it('closes the popover when card is deselected', async () => {
const wrapper = mountComponent({
props: { isSelected: true }
})
// Change isSelected from true to false
await wrapper.setProps({ isSelected: false })
await nextTick()
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
it('does not close the popover when card is selected', async () => {
const wrapper = mountComponent({
props: { isSelected: false }
})
// Change isSelected from false to true
await wrapper.setProps({ isSelected: true })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
it('does not close the popover when isSelected remains false', async () => {
const wrapper = mountComponent({
props: { isSelected: false }
})
// Change isSelected from false to false (no change)
await wrapper.setProps({ isSelected: false })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
it('does not close the popover when isSelected remains true', async () => {
const wrapper = mountComponent({
props: { isSelected: true }
})
// Change isSelected from true to true (no change)
await wrapper.setProps({ isSelected: true })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
})
})

View File

@@ -33,7 +33,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
@@ -43,9 +43,8 @@ import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
const { nodePack, isSelected } = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
isSelected: boolean
}>()
const popoverRef = ref()
@@ -70,14 +69,4 @@ const toggleVersionSelector = (event: Event) => {
const closeVersionSelector = () => {
popoverRef.value.hide()
}
// If the card is unselected, automatically close the version selector popover
watch(
() => isSelected,
(isSelected, wasSelected) => {
if (wasSelected && !isSelected) {
closeVersionSelector()
}
}
)
</script>

View File

@@ -191,100 +191,6 @@ describe('PackVersionSelectorPopover', () => {
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
describe('nodePack.id changes', () => {
it('re-fetches versions when nodePack.id changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
// Set up the mock for the second fetch
const newVersions = [
{ version: '2.0.0', createdAt: '2023-06-01' },
{ version: '1.9.0', createdAt: '2023-05-01' }
]
mockGetPackVersions.mockResolvedValueOnce(newVersions)
// Update the nodePack with a new ID
const newNodePack = {
...mockNodePack,
id: 'different-pack',
name: 'Different Pack'
}
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledTimes(2)
expect(mockGetPackVersions).toHaveBeenLastCalledWith(newNodePack.id)
// Check that new versions are displayed
const listbox = wrapper.findComponent(Listbox)
const options = listbox.props('options')!
expect(options.some((o) => o.value === '2.0.0')).toBe(true)
expect(options.some((o) => o.value === '1.9.0')).toBe(true)
})
it('does not re-fetch when nodePack changes but id remains the same', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
// Update the nodePack with same ID but different properties
const updatedNodePack = {
...mockNodePack,
name: 'Updated Test Pack',
description: 'New description'
}
await wrapper.setProps({ nodePack: updatedNodePack })
await waitForPromises()
// Should NOT fetch versions again
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
})
it('maintains selected version when switching to a new pack', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Select a specific version
const listbox = wrapper.findComponent(Listbox)
await listbox.setValue('0.9.0')
expect(listbox.props('modelValue')).toBe('0.9.0')
// Set up the mock for the second fetch
mockGetPackVersions.mockResolvedValueOnce([
{ version: '3.0.0', createdAt: '2023-07-01' },
{ version: '0.9.0', createdAt: '2023-04-01' }
])
// Update to a new pack that also has version 0.9.0
const newNodePack = {
id: 'another-pack',
name: 'Another Pack',
latest_version: { version: '3.0.0' }
}
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Selected version should remain the same if available
expect(listbox.props('modelValue')).toBe('0.9.0')
})
})
describe('Unclaimed GitHub packs handling', () => {
it('falls back to nightly when no versions exist', async () => {
// Set up the mock to return versions

View File

@@ -62,7 +62,7 @@ import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -161,11 +161,9 @@ const onNodePackChange = async () => {
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
void onNodePackChange()
}
() => nodePack,
() => {
void onNodePackChange()
},
{ deep: true, immediate: true }
)
@@ -184,4 +182,8 @@ const handleSubmit = async () => {
isQueueing.value = false
emit('submit')
}
onUnmounted(() => {
managerStore.installPack.clear()
})
</script>

View File

@@ -1,18 +1,16 @@
<template>
<Button
outlined
class="!m-0 p-0 rounded-lg"
:class="[
variant === 'black'
? 'bg-neutral-900 text-white border-neutral-900'
: 'border-neutral-700',
fullWidth ? 'w-full' : 'w-min-content'
]"
class="m-0 p-0 rounded-lg border-neutral-700"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
:disabled="loading"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2.5 px-3 whitespace-nowrap">
<span class="py-2.5 px-3">
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>
@@ -29,14 +27,12 @@ import Button from 'primevue/button'
const {
label,
loadingMessage,
fullWidth = false,
variant = 'default'
fullWidth = false
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
variant?: 'default' | 'black'
}>()
const emit = defineEmits<{

View File

@@ -2,11 +2,9 @@
<PackActionButton
v-bind="$attrs"
:label="
label ??
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
"
:severity="variant === 'black' ? undefined : 'secondary'"
:variant="variant"
severity="secondary"
:loading="isInstalling"
:loading-message="$t('g.installing')"
@action="installAllPacks"
@@ -29,10 +27,8 @@ import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, variant, label } = defineProps<{
const { nodePacks } = defineProps<{
nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
}>()
const isInstalling = inject(IsInstallingKey, ref(false))

View File

@@ -32,7 +32,7 @@
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
<PackVersionBadge :node-pack="nodePack" />
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
@@ -118,15 +118,7 @@ const onNodePackChange = () => {
y.value = 0
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
onNodePackChange()
}
},
{ immediate: true }
)
whenever(() => nodePack, onNodePackChange, { immediate: true, deep: true })
</script>
<style scoped>
.hidden-scrollbar {

View File

@@ -51,7 +51,6 @@ const isLoading = ref(false)
const registryNodeDefs = shallowRef<ListComfyNodesResponse | null>(null)
const fetchNodeDefs = async () => {
getNodeDefs.cancel()
isLoading.value = true
const { id: packId } = nodePack

View File

@@ -1,64 +0,0 @@
<template>
<div :style="{ width: cssWidth, height: cssHeight }" class="overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '100%',
height = '12rem'
} = defineProps<{
nodePack: components['schemas']['Node']
width?: string
height?: string
}>()
const isImageError = ref(false)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -1,21 +1,25 @@
<template>
<Card
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-lg shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-2xl shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
:class="{
'selected-card': isSelected,
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected,
'opacity-60': isDisabled
}"
:pt="{
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
content: { class: 'flex-1 flex flex-col rounded-lg min-h-0' },
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
content: { class: 'flex-1 flex flex-col rounded-2xl' },
title: {
class:
'self-stretch w-full px-4 py-3 inline-flex justify-start items-center gap-6'
},
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<PackBanner :node-pack="nodePack" />
<PackCardHeader :node-pack="nodePack" />
</template>
<template #content>
<ContentDivider />
<template v-if="isInstalling">
<div
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
@@ -30,66 +34,46 @@
</template>
<template v-else>
<div
class="self-stretch inline-flex flex-col justify-start items-start"
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
>
<PackIcon :node-pack="nodePack" />
<div
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
>
<div
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<div
class="self-stretch inline-flex justify-center items-center gap-2.5"
>
<span
class="text-base font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
</div>
<div
class="self-stretch inline-flex justify-start items-center gap-2"
>
<div
v-if="nodesCount"
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div class="text-center justify-center font-medium leading-3">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
class="self-stretch inline-flex justify-start items-center gap-1"
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<div
v-if="nodesCount"
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div
class="text-center justify-center font-medium leading-3"
>
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge
:node-pack="nodePack"
:is-selected="isSelected"
/>
</div>
<div
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
</div>
</div>
@@ -108,28 +92,21 @@ import { whenever } from '@vueuse/core'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
IsInstallingKey,
type MergedNodePack,
type RegistryPack,
isMergedNodePack
} from '@/types/comfyManagerTypes'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack, isSelected = false } = defineProps<{
nodePack: MergedNodePack | RegistryPack
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const { d } = useI18n()
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
@@ -143,40 +120,6 @@ const isDisabled = computed(
whenever(isInstalled, () => (isInstalling.value = false))
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
)
const publisherName = computed(() => {
if (!nodePack) return null
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
const formattedLatestVersionDate = computed(() => {
if (!nodePack.latest_version?.createdAt) return null
return d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
})
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
</script>
<style scoped>
.selected-card {
position: relative;
}
.selected-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 3px solid var(--p-primary-color);
border-radius: 0.5rem;
pointer-events: none;
z-index: 100;
}
</style>

View File

@@ -1,35 +1,39 @@
<template>
<div
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
<div class="flex items-center gap-2 cursor-pointer">
<span v-if="publisherName" class="max-w-40 truncate">
{{ publisherName }}
</span>
</div>
<div
v-if="nodePack.latest_version?.createdAt"
class="flex items-center gap-2 truncate"
>
{{ $t('g.updated') }}
{{
$d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
}}
</div>
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
<PackEnableToggle v-else :node-pack="nodePack" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const publisherName = computed(() => {
if (!nodePack) return null
const { n } = useI18n()
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
</script>

View File

@@ -1,37 +1,28 @@
<template>
<div class="relative w-full p-6">
<div class="h-12 flex items-center gap-1 justify-between">
<div class="flex items-center w-5/12">
<AutoComplete
v-model.lazy="searchQuery"
:suggestions="suggestions || []"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full"
:pt="{
pcInputText: {
root: {
autofocus: true,
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
<div class="flex items-center w-full">
<AutoComplete
v-model.lazy="searchQuery"
:suggestions="suggestions || []"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full"
:pt="{
pcInputText: {
root: {
autofocus: true,
class: 'w-5/12 rounded-2xl'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
<PackInstallButton
v-if="isMissingTab && missingNodePacks.length > 0"
variant="black"
:disabled="isLoading || !!error"
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
<div class="flex mt-3 text-sm">
@@ -43,7 +34,7 @@
/>
<SearchFilterDropdown
v-model:modelValue="sortField"
:options="availableSortOptions"
:options="sortOptions"
:label="$t('g.sort')"
/>
</div>
@@ -64,55 +55,43 @@ import AutoComplete, {
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
SearchMode,
SortableField
} from '@/types/searchServiceTypes'
const { searchResults, sortOptions } = defineProps<{
const { searchResults } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
suggestions?: NodesIndexSuggestion[]
}>()
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
const sortField = defineModel<string>('sortField', {
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
const sortField = defineModel<SortableAlgoliaField>('sortField', {
default: SortableAlgoliaField.Downloads
})
const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
const availableSortOptions = computed<SearchOption<string>[]>(() => {
if (!sortOptions) return []
return sortOptions.map((field) => ({
id: field.id,
label: field.label
}))
})
const filterOptions: SearchOption<SearchMode>[] = [
const sortOptions: SearchOption<SortableAlgoliaField>[] = [
{ id: SortableAlgoliaField.Downloads, label: t('manager.sort.downloads') },
{ id: SortableAlgoliaField.Created, label: t('manager.sort.created') },
{ id: SortableAlgoliaField.Updated, label: t('manager.sort.updated') },
{ id: SortableAlgoliaField.Publisher, label: t('manager.sort.publisher') },
{ id: SortableAlgoliaField.Name, label: t('g.name') }
]
const filterOptions: SearchOption<string>[] = [
{ id: 'packs', label: t('manager.filter.nodePack') },
{ id: 'nodes', label: t('g.nodes') }
]
// When a dropdown query suggestion is selected, update the search query
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
}

View File

@@ -54,21 +54,4 @@ describe('SettingItem', () => {
{ text: 'Correctly Translated', value: 'Correctly Translated' }
])
})
it('handles tooltips with @ symbols without errors', () => {
const wrapper = mountComponent({
setting: {
id: 'TestSetting',
name: 'Test Setting',
type: 'boolean',
tooltip:
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
}
})
// Should not throw an error and tooltip should be preserved as-is
expect(wrapper.vm.formItem.tooltip).toBe(
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
)
})
})

View File

@@ -28,7 +28,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '@/components/common/FormItem.vue'
import { st } from '@/i18n'
import { useSettingStore } from '@/stores/settingStore'
import type { SettingOption, SettingParams } from '@/types/settingTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -65,7 +64,7 @@ const formItem = computed(() => {
...props.setting,
name: t(`settings.${normalizedId}.name`, props.setting.name),
tooltip: props.setting.tooltip
? st(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
? t(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
: undefined,
options: props.setting.options
? translateOptions(props.setting.options)

View File

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

View File

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

View File

@@ -1,124 +1,113 @@
<template>
<div class="h-full">
<SidebarTabTemplate
v-if="!isHelpOpen"
:title="$t('sideToolbar.nodeLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.newFolder')"
class="new-folder-button"
icon="pi pi-folder-plus"
text
severity="secondary"
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupBy')"
:icon="selectedGroupingIcon"
text
severity="secondary"
@click="groupingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortMode')"
:icon="selectedSortingIcon"
text
severity="secondary"
@click="sortingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
icon="pi pi-refresh"
text
severity="secondary"
@click="resetOrganization"
/>
<Popover ref="groupingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in groupingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedGroupingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectGrouping(option.id)"
/>
</div>
</Popover>
<Popover ref="sortingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in sortingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedSortingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectSorting(option.id)"
/>
</div>
</Popover>
</template>
<template #header>
<div>
<SearchBox
v-model:modelValue="searchQuery"
class="node-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
@search="handleSearch"
@show-filter="($event) => searchFilter?.toggle($event)"
@remove-filter="onRemoveFilter"
<SidebarTabTemplate
:title="$t('sideToolbar.nodeLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.newFolder')"
class="new-folder-button"
icon="pi pi-folder-plus"
text
severity="secondary"
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.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)"
/>
<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"
</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)"
/>
<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>
</Popover>
</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"
/>
<NodeHelpPage v-else :node="currentHelpNode!" @close="closeHelp" />
</div>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @add-filter="onAddFilter" />
</Popover>
</template>
<template #body>
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-show="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
class="m-2"
/>
<TreeExplorer
v-model:expandedKeys="expandedKeys"
class="node-lib-tree-explorer"
:root="renderedRoot"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
</template>
</SidebarTabTemplate>
<div id="node-library-node-preview-container" />
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
@@ -130,7 +119,6 @@ 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'
@@ -141,7 +129,6 @@ import {
} from '@/services/nodeOrganizationService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import type {
GroupingStrategyId,
SortingStrategyId
@@ -154,7 +141,6 @@ 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)
@@ -175,9 +161,6 @@ const selectedSortingId = useLocalStorage<SortingStrategyId>(
const searchQuery = ref<string>('')
const { currentHelpNode, isHelpOpen } = storeToRefs(nodeHelpStore)
const { openHelp, closeHelp } = nodeHelpStore
const groupingOptions = computed(() =>
nodeOrganizationService.getGroupingStrategies().map((strategy) => ({
id: strategy.id,

View File

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

View File

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

View File

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

View File

@@ -46,68 +46,10 @@ vi.mock('@vueuse/core', () => ({
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => `/fileURL${path}`,
apiURL: (path: string) => `/apiURL${path}`,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
apiURL: (path: string) => `/apiURL${path}`
}
}))
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
vi.mock('@/stores/workflowTemplatesStore', () => ({
useWorkflowTemplatesStore: () => ({
isLoaded: true,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
groupedTemplates: []
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, fallback: string) => fallback || key
})
}))
vi.mock('@/composables/useTemplateWorkflows', () => ({
useTemplateWorkflows: () => ({
getTemplateThumbnailUrl: (
template: TemplateInfo,
sourceModule: string,
index = ''
) => {
const basePath =
sourceModule === 'default'
? `/fileURL/templates/${template.name}`
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
},
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
},
getTemplateDescription: (template: TemplateInfo, sourceModule: string) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
},
loadWorkflowTemplate: vi.fn()
})
}))
describe('TemplateWorkflowCard', () => {
const createTemplate = (overrides = {}): TemplateInfo => ({
name: 'test-template',

View File

@@ -86,7 +86,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { api } from '@/scripts/api'
import { TemplateInfo } from '@/types/workflowTemplateTypes'
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
@@ -102,36 +102,36 @@ const { sourceModule, loading, template } = defineProps<{
const cardRef = ref<HTMLElement | null>(null)
const isHovered = useElementHover(cardRef)
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
useTemplateWorkflows()
const getThumbnailUrl = (index = '') => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
// Determine the effective source module to use (from template or prop)
const effectiveSourceModule = computed(
() => template.sourceModule || sourceModule
)
// For templates from custom nodes, multiple images is not yet supported
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
const baseThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '1' : ''
)
getThumbnailUrl(sourceModule === 'default' ? '1' : '')
)
const overlayThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '2' : ''
)
getThumbnailUrl(sourceModule === 'default' ? '2' : '')
)
const description = computed(() =>
getTemplateDescription(template, effectiveSourceModule.value)
)
const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
const description = computed(() => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description.replace(/[-_]/g, ' ').trim()
})
const title = computed(() => {
return sourceModule === 'default'
? template.localizedTitle ?? ''
: template.name
})
defineEmits<{
loadWorkflow: [name: string]

View File

@@ -1,19 +1,21 @@
<template>
<DataTable
v-model:selection="selectedTemplate"
:value="enrichedTemplates"
:value="templates"
striped-rows
selection-mode="single"
>
<Column field="title" :header="$t('g.title')">
<template #body="slotProps">
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
<span :title="getTemplateTitle(slotProps.data)">{{
getTemplateTitle(slotProps.data)
}}</span>
</template>
</Column>
<Column field="description" :header="$t('g.description')">
<template #body="slotProps">
<span :title="slotProps.data.description">
{{ slotProps.data.description }}
<span :title="getTemplateDescription(slotProps.data)">
{{ getTemplateDescription(slotProps.data) }}
</span>
</template>
</Column>
@@ -36,9 +38,8 @@
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
const { sourceModule, loading, templates } = defineProps<{
@@ -49,20 +50,21 @@ const { sourceModule, loading, templates } = defineProps<{
}>()
const selectedTemplate = ref(null)
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
const enrichedTemplates = computed(() => {
return templates.map((template) => {
const actualSourceModule = template.sourceModule || sourceModule
return {
...template,
title: getTemplateTitle(template, actualSourceModule),
description: getTemplateDescription(template, actualSourceModule)
}
})
})
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
const getTemplateTitle = (template: TemplateInfo) => {
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
}
const getTemplateDescription = (template: TemplateInfo) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description.replace(/[-_]/g, ' ').trim()
}
</script>

View File

@@ -20,12 +20,12 @@
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
>
<ProgressSpinner
v-if="!isTemplatesLoaded || !isReady"
v-if="!workflowTemplatesStore.isLoaded || !isReady"
class="absolute w-8 h-full inset-0"
/>
<TemplateWorkflowsSideNav
:tabs="allTemplateGroups"
:selected-tab="selectedTemplate"
:tabs="tabs"
:selected-tab="selectedTab"
@update:selected-tab="handleTabSelection"
/>
</aside>
@@ -37,14 +37,14 @@
}"
>
<TemplateWorkflowView
v-if="isReady && selectedTemplate"
v-if="isReady && selectedTab"
class="px-12 py-4"
:title="selectedTemplate.title"
:source-module="selectedTemplate.moduleName"
:templates="selectedTemplate.templates"
:loading="loadingTemplateId"
:category-title="selectedTemplate.title"
@load-workflow="handleLoadWorkflow"
:title="selectedTab.title"
:source-module="selectedTab.moduleName"
:templates="selectedTab.templates"
:loading="workflowLoading"
:category-title="selectedTab.title"
@load-workflow="loadWorkflow"
/>
</div>
</div>
@@ -56,46 +56,47 @@ import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
const { t } = useI18n()
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const {
selectedTemplate,
loadingTemplateId,
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
watch(
isReady,
() => {
if (isReady.value) {
selectFirstTemplateCategory()
}
},
{ once: true }
const workflowTemplatesStore = useWorkflowTemplatesStore()
const { isReady } = useAsyncState(
workflowTemplatesStore.loadWorkflowTemplates,
null
)
const selectedTab = ref<WorkflowTemplates | null>(null)
const selectFirstTab = () => {
const firstTab = workflowTemplatesStore.groupedTemplates[0].modules[0]
handleTabSelection(firstTab)
}
watch(isReady, selectFirstTab, { once: true })
const workflowLoading = ref<string | null>(null)
const tabs = computed(() => workflowTemplatesStore.groupedTemplates)
const handleTabSelection = (selection: WorkflowTemplates | null) => {
if (selection !== null) {
selectTemplateCategory(selection)
//Listbox allows deselecting so this special case is ignored here
if (selection !== selectedTab.value && selection !== null) {
selectedTab.value = selection
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
@@ -104,9 +105,30 @@ const handleTabSelection = (selection: WorkflowTemplates | null) => {
}
}
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false
const loadWorkflow = async (id: string) => {
if (!isReady.value) return
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
workflowLoading.value = id
let json
if (selectedTab.value?.moduleName === 'default') {
// Default templates provided by frontend are served on this separate endpoint
json = await fetch(api.fileURL(`/templates/${id}.json`)).then((r) =>
r.json()
)
} else {
json = await fetch(
api.apiURL(
`/workflow_templates/${selectedTab.value?.moduleName}/${id}.json`
)
).then((r) => r.json())
}
useDialogStore().closeDialog()
const workflowName =
selectedTab.value?.moduleName === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
await app.loadGraphData(json, true, true, workflowName)
return false
}
</script>

View File

@@ -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: 50%')
expect(divider.attributes('style')).toContain('left: 21%')
})
it('passes isHovered prop to BaseThumbnail', () => {

View File

@@ -38,7 +38,7 @@ import { ref, watch } from 'vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const SLIDER_START_POSITION = 50
const SLIDER_START_POSITION = 21
const { baseImageSrc, overlayImageSrc, isHovered, isVideo } = defineProps<{
baseImageSrc: string

View File

@@ -101,7 +101,6 @@ Composables for sidebar functionality:
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
- `useQueueSidebarTab` - Manages the queue sidebar tab
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
- `useTemplateWorkflows` - Manages template workflow loading, selection, and display
### Widgets

View File

@@ -1,54 +0,0 @@
import {
ManagerState,
ManagerTab,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
const STORAGE_KEY = 'Comfy.Manager.UI.State'
export const useManagerStatePersistence = () => {
/**
* Load the UI state from localStorage.
*/
const loadStoredState = (): ManagerState => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
return JSON.parse(stored)
}
} catch (e) {
console.error('Failed to load manager UI state:', e)
}
return {
selectedTabId: ManagerTab.All,
searchQuery: '',
searchMode: 'packs',
sortField: SortableAlgoliaField.Downloads
}
}
/**
* Persist the UI state to localStorage.
*/
const persistState = (state: ManagerState) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
/**
* Reset the UI state to the default values.
*/
const reset = () => {
persistState({
selectedTabId: ManagerTab.All,
searchQuery: '',
searchMode: 'packs',
sortField: SortableAlgoliaField.Downloads
})
}
return {
loadStoredState,
persistState,
reset
}
}

View File

@@ -7,7 +7,6 @@ import _ from 'lodash'
import { computed, onMounted, watch } from 'vue'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
@@ -112,15 +111,10 @@ export const useNodeBadge = () => {
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
// Get the pricing function to determine if this node has dynamic pricing
const pricingConfig = nodePricing.getNodePricingConfig(node)
const hasDynamicPricing =
typeof pricingConfig?.displayPrice === 'function'
let creditsBadge
const createBadge = () => {
const price = nodePricing.getNodeDisplayPrice(node)
const price = nodePricing.getNodeDisplayPrice(node)
// Always add the badge for API nodes, with or without price text
const creditsBadge = computed(() => {
// Use dynamic background color based on the theme
const isLightTheme =
colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
@@ -143,24 +137,7 @@ export const useNodeBadge = () => {
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
if (hasDynamicPricing) {
// For dynamic pricing nodes, use computed that watches widget changes
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
node.constructor.nodeData?.name
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
widgetNames: relevantWidgetNames,
triggerCanvasRedraw: true
})
creditsBadge = computedWithWidgetWatch(createBadge)
} else {
// For static pricing nodes, use regular computed
creditsBadge = computed(createBadge)
}
})
node.badges.push(() => creditsBadge.value)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { computedWithControl } from '@vueuse/core'
import { type ComputedRef, ref } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
export interface UseComputedWithWidgetWatchOptions {
/**
* Names of widgets to observe for changes.
* If not provided, all widgets will be observed.
*/
widgetNames?: string[]
/**
* Whether to trigger a canvas redraw when widget values change.
* @default false
*/
triggerCanvasRedraw?: boolean
}
/**
* A composable that creates a computed that has a node's widget values as a dependencies.
* Essentially `computedWithControl` (https://vueuse.org/shared/computedWithControl/) where
* the explicitly defined extra dependencies are LGraphNode widgets.
*
* @param node - The LGraphNode whose widget values are to be watched
* @param options - Configuration options for the watcher
* @returns A function to create computed that responds to widget changes
*
* @example
* ```ts
* const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
* widgetNames: ['width', 'height'],
* triggerCanvasRedraw: true
* })
*
* const dynamicPrice = computedWithWidgetWatch(() => {
* return calculatePrice(node)
* })
* ```
*/
export const useComputedWithWidgetWatch = (
node: LGraphNode,
options: UseComputedWithWidgetWatchOptions = {}
) => {
const { widgetNames, triggerCanvasRedraw = false } = options
// Create a reactive trigger based on widget values
const widgetValues = ref<Record<string, any>>({})
// Initialize widget observers
if (node.widgets) {
const widgetsToObserve = widgetNames
? node.widgets.filter((widget) => widgetNames.includes(widget.name))
: node.widgets
// Initialize current values
const currentValues: Record<string, any> = {}
widgetsToObserve.forEach((widget) => {
currentValues[widget.name] = widget.value
})
widgetValues.value = currentValues
widgetsToObserve.forEach((widget) => {
widget.callback = useChainCallback(widget.callback, () => {
// Update the reactive widget values
widgetValues.value = {
...widgetValues.value,
[widget.name]: widget.value
}
// Optionally trigger a canvas redraw
if (triggerCanvasRedraw) {
node.graph?.setDirtyCanvas(true, true)
}
})
})
}
// Returns a function that creates a computed that responds to widget changes.
// The computed will be re-evaluated whenever any observed widget changes.
return <T>(computeFn: () => T): ComputedRef<T> => {
return computedWithControl(widgetValues, computeFn)
}
}

View File

@@ -1,39 +0,0 @@
import { computed, onMounted } from 'vue'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
/**
* Composable to find missing NodePacks from workflow
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches workflow pack data when initialized
*/
export const useMissingNodes = () => {
const comfyManagerStore = useComfyManagerStore()
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
useWorkflowPacks()
// Same filtering logic as ManagerDialogContent.vue
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
// Filter only uninstalled packs from workflow packs
const missingNodePacks = computed(() => {
if (!workflowPacks.value.length) return []
return filterMissingPacks(workflowPacks.value)
})
// Automatically fetch workflow pack data when composable is used
onMounted(async () => {
if (!workflowPacks.value.length && !isLoading.value) {
await startFetchWorkflowPacks()
}
})
return {
missingNodePacks,
isLoading,
error
}
}

View File

@@ -4,7 +4,6 @@ import {
LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import { Point } from '@comfyorg/litegraph'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import {
@@ -28,8 +27,6 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
@@ -61,20 +58,6 @@ export function useCoreCommands(): ComfyCommand[] {
})
}
const moveSelectedNodes = (
positionUpdater: (pos: Point, gridSize: number) => Point
) => {
const selectedNodes = getSelectedNodes()
if (selectedNodes.length === 0) return
const gridSize = useSettingStore().get('Comfy.SnapToGrid.GridSize')
selectedNodes.forEach((node) => {
node.pos = positionUpdater(node.pos, gridSize)
})
app.canvas.state.selectionChanged = true
app.canvas.setDirty(true, true)
}
const commands = [
{
id: 'Comfy.NewBlankWorkflow',
@@ -658,19 +641,19 @@ export function useCoreCommands(): ComfyCommand[] {
{
id: 'Comfy.Manager.CustomNodesManager',
icon: 'pi pi-puzzle',
label: 'Toggle the Custom Nodes Manager',
label: 'Custom Nodes Manager',
versionAdded: '1.12.10',
function: () => {
dialogService.toggleManagerDialog()
dialogService.showManagerDialog()
}
},
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle the Custom Nodes Manager Progress Bar',
label: 'Toggle Progress Dialog',
versionAdded: '1.13.9',
function: () => {
dialogService.toggleManagerProgressDialog()
dialogService.showManagerProgressDialog()
}
},
{
@@ -690,34 +673,6 @@ export function useCoreCommands(): ComfyCommand[] {
function: async () => {
await firebaseAuthActions.logout()
}
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Up',
icon: 'pi pi-arrow-up',
label: 'Move Selected Nodes Up',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y - gridSize])
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Down',
icon: 'pi pi-arrow-down',
label: 'Move Selected Nodes Down',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y + gridSize])
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Left',
icon: 'pi pi-arrow-left',
label: 'Move Selected Nodes Left',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x - gridSize, y])
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Right',
icon: 'pi pi-arrow-right',
label: 'Move Selected Nodes Right',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
}
]

View File

@@ -15,10 +15,7 @@ export const useProgressFavicon = () => {
if (isIdle) {
favicon.value = defaultFavicon
} else {
const frame = Math.min(
Math.max(0, Math.floor(progress * totalFrames)),
totalFrames - 1
)
const frame = Math.floor(progress * totalFrames)
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
}
}

View File

@@ -1,61 +1,91 @@
import { watchDebounced } from '@vueuse/core'
import { orderBy } from 'lodash'
import { computed, ref, watch } from 'vue'
import type { Hit } from 'algoliasearch/dist/lite/browser'
import { memoize, orderBy } from 'lodash'
import { computed, onUnmounted, ref, watch } from 'vue'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { SearchAttribute } from '@/types/algoliaTypes'
import {
AlgoliaNodePack,
SearchAttribute,
useAlgoliaSearchService
} from '@/services/algoliaSearchService'
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
const SEARCH_DEBOUNCE_TIME = 320
const DEFAULT_PAGE_SIZE = 64
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
const DEFAULT_MAX_CACHE_SIZE = 64
const SORT_DIRECTIONS: Record<SortableAlgoliaField, 'asc' | 'desc'> = {
[SortableAlgoliaField.Downloads]: 'desc',
[SortableAlgoliaField.Created]: 'desc',
[SortableAlgoliaField.Updated]: 'desc',
[SortableAlgoliaField.Publisher]: 'asc',
[SortableAlgoliaField.Name]: 'asc'
}
const isDateField = (field: SortableAlgoliaField): boolean =>
field === SortableAlgoliaField.Created ||
field === SortableAlgoliaField.Updated
/**
* Composable for managing UI state of Comfy Node Registry search.
*/
export function useRegistrySearch(
options: {
initialSortField?: string
initialSearchMode?: SearchMode
initialSearchQuery?: string
initialPageNumber?: number
maxCacheSize?: number
} = {}
) {
const {
initialSortField = DEFAULT_SORT_FIELD,
initialSearchMode = 'packs',
initialSearchQuery = '',
initialPageNumber = 0
} = options
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
const isLoading = ref(false)
const sortField = ref<string>(initialSortField)
const searchMode = ref<SearchMode>(initialSearchMode)
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
const searchMode = ref<'nodes' | 'packs'>('packs')
const pageSize = ref(DEFAULT_PAGE_SIZE)
const pageNumber = ref(initialPageNumber)
const searchQuery = ref(initialSearchQuery)
const searchResults = ref<RegistryNodePack[]>([])
const suggestions = ref<QuerySuggestion[]>([])
const pageNumber = ref(0)
const searchQuery = ref('')
const results = ref<AlgoliaNodePack[]>([])
const suggestions = ref<NodesIndexSuggestion[]>([])
const searchAttributes = computed<SearchAttribute[]>(() =>
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
)
const searchGateway = useRegistrySearchGateway()
const resultsAsRegistryPacks = computed(() =>
results.value ? results.value.map(algoliaToRegistry) : []
)
const resultsAsNodes = computed(() =>
results.value
? results.value.reduce(
(acc, hit) => acc.concat(hit.comfy_nodes),
[] as string[]
)
: []
)
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
searchGateway
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
useAlgoliaSearchService({
maxCacheSize
})
const algoliaToRegistry = memoize(
toRegistryPack,
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
)
const getSortValue = (pack: Hit<AlgoliaNodePack>) => {
if (isDateField(sortField.value)) {
const value = pack[sortField.value]
return value ? new Date(value).getTime() : 0
} else {
const value = pack[sortField.value]
return value ?? 0
}
}
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacks(
const { nodePacks, querySuggestions } = await searchPacksCached(
searchQuery.value,
{
pageSize: pageSize.value,
@@ -68,22 +98,17 @@ export function useRegistrySearch(
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const direction = fieldConfig?.direction || 'desc'
sortedPacks = orderBy(
nodePacks,
[(pack) => getSortValue(pack, sortField.value)],
[direction]
[getSortValue],
[SORT_DIRECTIONS[sortField.value]]
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
if (options.append && results.value?.length) {
results.value = results.value.concat(sortedPacks)
} else {
searchResults.value = sortedPacks
results.value = sortedPacks
}
suggestions.value = querySuggestions
isLoading.value = false
@@ -99,9 +124,7 @@ export function useRegistrySearch(
immediate: true
})
const sortOptions = computed(() => {
return getSortableFields()
})
onUnmounted(clearSearchPacksCache)
return {
isLoading,
@@ -111,8 +134,7 @@ export function useRegistrySearch(
searchMode,
searchQuery,
suggestions,
searchResults,
sortOptions,
clearCache: clearSearchCache
searchResults: resultsAsRegistryPacks,
nodeSearchResults: resultsAsNodes
}
}

View File

@@ -1,190 +0,0 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/types/workflowTemplateTypes'
export function useTemplateWorkflows() {
const { t } = useI18n()
const workflowTemplatesStore = useWorkflowTemplatesStore()
const dialogStore = useDialogStore()
// State
const selectedTemplate = ref<WorkflowTemplates | null>(null)
const loadingTemplateId = ref<string | null>(null)
// Computed
const isTemplatesLoaded = computed(() => workflowTemplatesStore.isLoaded)
const allTemplateGroups = computed<TemplateGroup[]>(
() => workflowTemplatesStore.groupedTemplates
)
/**
* Loads all template workflows from the API
*/
const loadTemplates = async () => {
if (!workflowTemplatesStore.isLoaded) {
await workflowTemplatesStore.loadWorkflowTemplates()
}
return workflowTemplatesStore.isLoaded
}
/**
* Selects the first template category as default
*/
const selectFirstTemplateCategory = () => {
if (allTemplateGroups.value.length > 0) {
const firstCategory = allTemplateGroups.value[0].modules[0]
selectTemplateCategory(firstCategory)
}
}
/**
* Selects a template category
*/
const selectTemplateCategory = (category: WorkflowTemplates | null) => {
selectedTemplate.value = category
return category !== null
}
/**
* Gets template thumbnail URL
*/
const getTemplateThumbnailUrl = (
template: TemplateInfo,
sourceModule: string,
index = ''
) => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
/**
* Gets formatted template title
*/
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
}
/**
* Gets formatted template description
*/
const getTemplateDescription = (
template: TemplateInfo,
sourceModule: string
) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
}
/**
* Loads a workflow template
*/
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
if (!isTemplatesLoaded.value) return false
loadingTemplateId.value = id
let json
try {
// Handle "All" category as a special case
if (sourceModule === 'all') {
// Find "All" category in the ComfyUI Examples group
const comfyExamplesGroup = allTemplateGroups.value.find(
(g) =>
g.label ===
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
)
const allCategory = comfyExamplesGroup?.modules.find(
(m) => m.moduleName === 'all'
)
const template = allCategory?.templates.find((t) => t.name === id)
if (!template || !template.sourceModule) return false
// Use the stored source module for loading
const actualSourceModule = template.sourceModule
json = await fetchTemplateJson(id, actualSourceModule)
// Use source module for name
const workflowName =
actualSourceModule === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)
return true
}
// Regular case for normal categories
json = await fetchTemplateJson(id, sourceModule)
const workflowName =
sourceModule === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)
return true
} catch (error) {
console.error('Error loading workflow template:', error)
return false
} finally {
loadingTemplateId.value = null
}
}
/**
* Fetches template JSON from the appropriate endpoint
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
if (sourceModule === 'default') {
// Default templates provided by frontend are served on this separate endpoint
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
} else {
return fetch(
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
).then((r) => r.json())
}
}
return {
// State
selectedTemplate,
loadingTemplateId,
// Computed
isTemplatesLoaded,
allTemplateGroups,
// Methods
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription,
loadWorkflowTemplate
}
}

View File

@@ -9,7 +9,6 @@ import type { ResultItem } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
@@ -34,13 +33,7 @@ export const useImageUploadWidget = () => {
inputName: string,
inputData: InputSpec
) => {
if (!isImageUploadInput(inputData)) {
throw new Error(
'Image upload widget requires imageInputName augmentation'
)
}
const inputOptions = inputData[1]
const inputOptions = inputData[1] ?? {}
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const nodeOutputStore = useNodeOutputStore()
@@ -50,9 +43,11 @@ export const useImageUploadWidget = () => {
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const fileFilter = isVideo ? isVideoFile : isImageFile
// @ts-expect-error InputSpec is not typed correctly
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const formatPath = (value: InternalFile) =>
// @ts-expect-error InputSpec is not typed correctly
createAnnotatedPath(value, { rootFolder: image_folder })
const transform = (internalValue: InternalValue): ExposedValue => {
@@ -72,6 +67,7 @@ export const useImageUploadWidget = () => {
// Setup file upload handling
const { openFileSelection } = useNodeImageUpload(node, {
// @ts-expect-error InputSpec is not typed correctly
allow_batch,
fileFilter,
accept,

View File

@@ -1,3 +0,0 @@
export const SEARCH_CACHE_MAX_SIZE = 64
export const DEFAULT_PAGE_SIZE = 64
export const MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA = 2

View File

@@ -160,43 +160,22 @@ class Load3d {
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.INITIAL_RENDER_DONE = true
}
renderMainScene(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(true)
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
this.INITIAL_RENDER_DONE = true
}
private getActiveCamera(): THREE.Camera {
@@ -219,17 +198,20 @@ class Load3d {
return
}
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
const delta = this.clock.getDelta()
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
@@ -316,18 +298,17 @@ class Load3d {
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)
this.previewManager.setPreviewBackgroundColor(color)
this.forceRender()
}
async setBackgroundImage(uploadPath: string): Promise<void> {
await this.sceneManager.setBackgroundImage(uploadPath)
this.previewManager.updateBackgroundTexture(
this.sceneManager.backgroundTexture
)
if (this.previewManager.previewRenderer) {
this.previewManager.updateBackgroundTexture(
this.sceneManager.backgroundTexture
)
}
this.forceRender()
}
@@ -335,9 +316,12 @@ class Load3d {
removeBackgroundImage(): void {
this.sceneManager.removeBackgroundImage()
this.previewManager.setPreviewBackgroundColor(
this.sceneManager.currentBackgroundColor
)
if (
this.previewManager.previewRenderer &&
this.previewManager.previewCamera
) {
this.previewManager.updateBackgroundTexture(null)
}
this.forceRender()
}
@@ -364,6 +348,10 @@ class Load3d {
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
if (this.previewManager.showPreview) {
this.previewManager.syncWithMainCamera()
}
this.forceRender()
}

View File

@@ -42,6 +42,10 @@ class Load3dAnimation extends Load3d {
return
}
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
const delta = this.clock.getDelta()
this.animationManager.update(delta)
@@ -50,13 +54,12 @@ class Load3dAnimation extends Load3d {
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)

View File

@@ -4,8 +4,9 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { EventManagerInterface, PreviewManagerInterface } from './interfaces'
export class PreviewManager implements PreviewManagerInterface {
previewRenderer: THREE.WebGLRenderer | null = null
previewCamera: THREE.Camera
previewContainer: HTMLDivElement = null!
previewContainer: HTMLDivElement = {} as HTMLDivElement
showPreview: boolean = true
previewWidth: number = 120
@@ -16,6 +17,7 @@ export class PreviewManager implements PreviewManagerInterface {
private getControls: () => OrbitControls
private eventManager: EventManagerInterface
// @ts-expect-error unused variable
private getRenderer: () => THREE.WebGLRenderer
private previewBackgroundScene: THREE.Scene
@@ -23,9 +25,6 @@ export class PreviewManager implements PreviewManagerInterface {
private previewBackgroundMesh: THREE.Mesh | null = null
private previewBackgroundTexture: THREE.Texture | null = null
private previewBackgroundColorMaterial: THREE.MeshBasicMaterial | null = null
private currentBackgroundColor: THREE.Color = new THREE.Color(0x282828)
constructor(
scene: THREE.Scene,
getActiveCamera: () => THREE.Camera,
@@ -46,24 +45,15 @@ export class PreviewManager implements PreviewManagerInterface {
this.previewBackgroundScene = backgroundScene.clone()
this.previewBackgroundCamera = backgroundCamera.clone()
this.initPreviewBackgroundScene()
}
private initPreviewBackgroundScene(): void {
const planeGeometry = new THREE.PlaneGeometry(2, 2)
this.previewBackgroundColorMaterial = new THREE.MeshBasicMaterial({
color: this.currentBackgroundColor.clone(),
transparent: false,
const planeMaterial = new THREE.MeshBasicMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.previewBackgroundMesh = new THREE.Mesh(
planeGeometry,
this.previewBackgroundColorMaterial
)
this.previewBackgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
this.previewBackgroundMesh.position.set(0, 0, 0)
this.previewBackgroundScene.add(this.previewBackgroundMesh)
}
@@ -71,23 +61,40 @@ export class PreviewManager implements PreviewManagerInterface {
init(): void {}
dispose(): void {
if (this.previewRenderer) {
this.previewRenderer.forceContextLoss()
const canvas = this.previewRenderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.previewRenderer.dispose()
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
if (this.previewBackgroundColorMaterial) {
this.previewBackgroundColorMaterial.dispose()
}
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.geometry.dispose()
if (this.previewBackgroundMesh.material instanceof THREE.Material) {
this.previewBackgroundMesh.material.dispose()
}
;(this.previewBackgroundMesh.material as THREE.Material).dispose()
}
}
createCapturePreview(container: Element | HTMLElement): void {
this.previewRenderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
preserveDrawingBuffer: true
})
this.previewRenderer.setSize(this.targetWidth, this.targetHeight)
this.previewRenderer.setClearColor(0x282828)
this.previewRenderer.autoClear = false
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
this.previewContainer = document.createElement('div')
this.previewContainer.style.cssText = `
position: absolute;
@@ -97,6 +104,7 @@ export class PreviewManager implements PreviewManagerInterface {
display: block;
transition: border-color 0.1s ease;
`
this.previewContainer.appendChild(this.previewRenderer.domElement)
const MIN_PREVIEW_WIDTH = 120
const MAX_PREVIEW_WIDTH = 240
@@ -123,6 +131,7 @@ export class PreviewManager implements PreviewManagerInterface {
}
this.updatePreviewSize()
this.updatePreviewRender()
})
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
@@ -150,54 +159,57 @@ export class PreviewManager implements PreviewManagerInterface {
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewContainer.style.width = `${this.previewWidth}px`
this.previewContainer.style.height = `${previewHeight}px`
this.previewRenderer?.setSize(this.previewWidth, previewHeight, false)
}
getPreviewViewport(): {
left: number
bottom: number
width: number
height: number
} | null {
if (!this.showPreview || !this.previewContainer) {
return null
syncWithMainCamera(): void {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview) {
return
}
const renderer = this.getRenderer()
const canvas = renderer.domElement
this.previewCamera = this.getActiveCamera().clone()
const containerRect = this.previewContainer.getBoundingClientRect()
const canvasRect = canvas.getBoundingClientRect()
this.previewCamera.position.copy(this.getActiveCamera().position)
this.previewCamera.rotation.copy(this.getActiveCamera().rotation)
if (
containerRect.bottom < canvasRect.top ||
containerRect.top > canvasRect.bottom ||
containerRect.right < canvasRect.left ||
containerRect.left > canvasRect.right
) {
return null
const aspect = this.targetWidth / this.targetHeight
if (this.getActiveCamera() instanceof THREE.OrthographicCamera) {
const activeOrtho = this.getActiveCamera() as THREE.OrthographicCamera
const previewOrtho = this.previewCamera as THREE.OrthographicCamera
previewOrtho.zoom = activeOrtho.zoom
const frustumHeight =
(activeOrtho.top - activeOrtho.bottom) / activeOrtho.zoom
const frustumWidth = frustumHeight * aspect
previewOrtho.top = frustumHeight / 2
previewOrtho.left = -frustumWidth / 2
previewOrtho.right = frustumWidth / 2
previewOrtho.bottom = -frustumHeight / 2
previewOrtho.updateProjectionMatrix()
} else {
const activePerspective =
this.getActiveCamera() as THREE.PerspectiveCamera
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
previewPerspective.fov = activePerspective.fov
previewPerspective.zoom = activePerspective.zoom
previewPerspective.aspect = aspect
previewPerspective.updateProjectionMatrix()
}
const width = parseFloat(this.previewContainer.style.width)
const height = parseFloat(this.previewContainer.style.height)
this.previewCamera.lookAt(this.getControls().target)
const left = this.getRenderer().domElement.clientWidth - width
const bottom = 0
return { left, bottom, width, height }
this.updatePreviewRender()
}
renderPreview(): void {
const viewport = this.getPreviewViewport()
if (!viewport) return
const renderer = this.getRenderer()
const originalClearColor = renderer.getClearColor(new THREE.Color())
const originalClearAlpha = renderer.getClearAlpha()
updatePreviewRender(): void {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
return
if (
!this.previewCamera ||
@@ -231,77 +243,45 @@ export class PreviewManager implements PreviewManagerInterface {
previewOrtho.updateProjectionMatrix()
} else {
const activePerspective =
;(this.previewCamera as THREE.PerspectiveCamera).aspect = aspect
;(this.previewCamera as THREE.PerspectiveCamera).fov = (
this.getActiveCamera() as THREE.PerspectiveCamera
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
previewPerspective.fov = activePerspective.fov
previewPerspective.zoom = activePerspective.zoom
previewPerspective.aspect = aspect
previewPerspective.updateProjectionMatrix()
).fov
;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix()
}
this.previewCamera.lookAt(this.getControls().target)
renderer.setViewport(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
renderer.setScissor(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewRenderer.setSize(this.previewWidth, previewHeight, false)
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
this.previewRenderer.clear()
renderer.setClearColor(0x000000, 0)
renderer.clear()
if (this.previewBackgroundMesh && this.previewBackgroundTexture) {
const material = this.previewBackgroundMesh
.material as THREE.MeshBasicMaterial
if (material.map) {
const currentToneMapping = this.previewRenderer.toneMapping
const currentExposure = this.previewRenderer.toneMappingExposure
this.renderPreviewBackground(renderer)
this.previewRenderer.toneMapping = THREE.NoToneMapping
this.previewRenderer.render(
this.previewBackgroundScene,
this.previewBackgroundCamera
)
renderer.render(this.scene, this.previewCamera)
renderer.setClearColor(originalClearColor, originalClearAlpha)
}
private renderPreviewBackground(renderer: THREE.WebGLRenderer): void {
if (this.previewBackgroundMesh) {
const currentToneMapping = renderer.toneMapping
const currentExposure = renderer.toneMappingExposure
renderer.toneMapping = THREE.NoToneMapping
renderer.render(this.previewBackgroundScene, this.previewBackgroundCamera)
renderer.toneMapping = currentToneMapping
renderer.toneMappingExposure = currentExposure
}
}
setPreviewBackgroundColor(color: string | number | THREE.Color): void {
this.currentBackgroundColor.set(color)
if (!this.previewBackgroundMesh || !this.previewBackgroundColorMaterial) {
this.initPreviewBackgroundScene()
this.previewRenderer.toneMapping = currentToneMapping
this.previewRenderer.toneMappingExposure = currentExposure
}
}
this.previewBackgroundColorMaterial!.color.copy(this.currentBackgroundColor)
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.material = this.previewBackgroundColorMaterial!
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
this.previewBackgroundTexture = null
}
this.previewRenderer.render(this.scene, this.previewCamera)
}
togglePreview(showPreview: boolean): void {
this.showPreview = showPreview
if (this.previewContainer) {
if (this.previewRenderer) {
this.showPreview = showPreview
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
}
@@ -326,7 +306,7 @@ export class PreviewManager implements PreviewManagerInterface {
)
}
if (this.previewCamera) {
if (this.previewRenderer && this.previewCamera) {
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
this.previewCamera.aspect = width / height
this.previewCamera.updateProjectionMatrix()
@@ -342,45 +322,30 @@ export class PreviewManager implements PreviewManagerInterface {
handleResize(): void {
this.updatePreviewSize()
this.updatePreviewRender()
}
updateBackgroundTexture(texture: THREE.Texture | null): void {
if (texture) {
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
this.previewBackgroundTexture = texture
this.previewBackgroundTexture = texture
if (this.previewBackgroundMesh) {
const imageMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
if (texture && this.previewBackgroundMesh) {
const material2 = this.previewBackgroundMesh
.material as THREE.MeshBasicMaterial
material2.map = texture
material2.needsUpdate = true
if (
this.previewBackgroundMesh.material instanceof THREE.Material &&
this.previewBackgroundMesh.material !==
this.previewBackgroundColorMaterial
) {
this.previewBackgroundMesh.material.dispose()
}
this.previewBackgroundMesh.position.set(0, 0, 0)
this.previewBackgroundMesh.material = imageMaterial
this.previewBackgroundMesh.position.set(0, 0, 0)
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
this.targetWidth,
this.targetHeight
)
}
} else {
this.setPreviewBackgroundColor(this.currentBackgroundColor)
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
this.targetWidth,
this.targetHeight
)
}
}

View File

@@ -13,10 +13,6 @@ export class SceneManager implements SceneManagerInterface {
backgroundMesh: THREE.Mesh | null = null
backgroundTexture: THREE.Texture | null = null
backgroundColorMaterial: THREE.MeshBasicMaterial | null = null
currentBackgroundType: 'color' | 'image' = 'color'
currentBackgroundColor: string = '#282828'
private eventManager: EventManagerInterface
private renderer: THREE.WebGLRenderer
@@ -44,28 +40,17 @@ export class SceneManager implements SceneManagerInterface {
this.backgroundScene = new THREE.Scene()
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
this.initBackgroundScene()
}
private initBackgroundScene(): void {
const planeGeometry = new THREE.PlaneGeometry(2, 2)
this.backgroundColorMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(this.currentBackgroundColor),
transparent: false,
const planeMaterial = new THREE.MeshBasicMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.backgroundMesh = new THREE.Mesh(
planeGeometry,
this.backgroundColorMaterial
)
this.backgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
this.backgroundMesh.position.set(0, 0, 0)
this.backgroundScene.add(this.backgroundMesh)
this.renderer.setClearColor(0x000000, 0)
}
init(): void {}
@@ -75,15 +60,9 @@ export class SceneManager implements SceneManagerInterface {
this.backgroundTexture.dispose()
}
if (this.backgroundColorMaterial) {
this.backgroundColorMaterial.dispose()
}
if (this.backgroundMesh) {
this.backgroundMesh.geometry.dispose()
if (this.backgroundMesh.material instanceof THREE.Material) {
this.backgroundMesh.material.dispose()
}
;(this.backgroundMesh.material as THREE.Material).dispose()
}
this.scene.clear()
@@ -98,39 +77,18 @@ export class SceneManager implements SceneManagerInterface {
}
setBackgroundColor(color: string): void {
this.currentBackgroundColor = color
this.currentBackgroundType = 'color'
if (!this.backgroundMesh || !this.backgroundColorMaterial) {
this.initBackgroundScene()
}
this.backgroundColorMaterial!.color.set(color)
this.backgroundColorMaterial!.map = null
this.backgroundColorMaterial!.transparent = false
this.backgroundColorMaterial!.needsUpdate = true
if (this.backgroundMesh) {
this.backgroundMesh.material = this.backgroundColorMaterial!
}
if (this.backgroundTexture) {
this.backgroundTexture.dispose()
this.backgroundTexture = null
}
this.renderer.setClearColor(new THREE.Color(color))
this.eventManager.emitEvent('backgroundColorChange', color)
}
async setBackgroundImage(uploadPath: string): Promise<void> {
if (uploadPath === '') {
this.setBackgroundColor(this.currentBackgroundColor)
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
if (uploadPath === '') {
this.removeBackgroundImage()
return
}
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
let imageUrl = Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath)
)
@@ -152,31 +110,12 @@ export class SceneManager implements SceneManagerInterface {
texture.colorSpace = THREE.SRGBColorSpace
this.backgroundTexture = texture
this.currentBackgroundType = 'image'
if (!this.backgroundMesh) {
this.initBackgroundScene()
}
const material = this.backgroundMesh?.material as THREE.MeshBasicMaterial
material.map = texture
material.needsUpdate = true
const imageMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
if (this.backgroundMesh) {
if (
this.backgroundMesh.material !== this.backgroundColorMaterial &&
this.backgroundMesh.material instanceof THREE.Material
) {
this.backgroundMesh.material.dispose()
}
this.backgroundMesh.material = imageMaterial
this.backgroundMesh.position.set(0, 0, 0)
}
this.backgroundMesh?.position.set(0, 0, 0)
this.updateBackgroundSize(
this.backgroundTexture,
@@ -190,12 +129,20 @@ export class SceneManager implements SceneManagerInterface {
} catch (error) {
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
console.error('Error loading background image:', error)
this.setBackgroundColor(this.currentBackgroundColor)
}
}
removeBackgroundImage(): void {
this.setBackgroundColor(this.currentBackgroundColor)
if (this.backgroundMesh) {
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
material.map = null
material.needsUpdate = true
}
if (this.backgroundTexture) {
this.backgroundTexture.dispose()
this.backgroundTexture = null
}
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
}
@@ -225,11 +172,7 @@ export class SceneManager implements SceneManagerInterface {
}
handleResize(width: number, height: number): void {
if (
this.backgroundTexture &&
this.backgroundMesh &&
this.currentBackgroundType === 'image'
) {
if (this.backgroundTexture && this.backgroundMesh) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
@@ -240,25 +183,18 @@ export class SceneManager implements SceneManagerInterface {
}
renderBackground(): void {
if (this.backgroundMesh) {
const currentToneMapping = this.renderer.toneMapping
const currentExposure = this.renderer.toneMappingExposure
if (this.backgroundMesh && this.backgroundTexture) {
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
if (material.map) {
const currentToneMapping = this.renderer.toneMapping
const currentExposure = this.renderer.toneMappingExposure
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = currentToneMapping
this.renderer.toneMappingExposure = currentExposure
}
}
getCurrentBackgroundInfo(): { type: 'color' | 'image'; value: string } {
return {
type: this.currentBackgroundType,
value:
this.currentBackgroundType === 'color'
? this.currentBackgroundColor
: ''
this.renderer.toneMapping = currentToneMapping
this.renderer.toneMappingExposure = currentExposure
}
}
}
@@ -274,6 +210,8 @@ export class SceneManager implements SceneManagerInterface {
new THREE.Color()
)
const originalClearAlpha = this.renderer.getClearAlpha()
const originalToneMapping = this.renderer.toneMapping
const originalExposure = this.renderer.toneMappingExposure
const originalOutputColorSpace = this.renderer.outputColorSpace
this.renderer.setSize(width, height)
@@ -299,11 +237,7 @@ export class SceneManager implements SceneManagerInterface {
orthographicCamera.updateProjectionMatrix()
}
if (
this.backgroundTexture &&
this.backgroundMesh &&
this.currentBackgroundType === 'image'
) {
if (this.backgroundTexture && this.backgroundMesh) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
@@ -318,7 +252,19 @@ export class SceneManager implements SceneManagerInterface {
>()
this.renderer.clear()
this.renderBackground()
if (this.backgroundMesh && this.backgroundTexture) {
const material = this.backgroundMesh
.material as THREE.MeshBasicMaterial
if (material.map) {
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = originalToneMapping
this.renderer.toneMappingExposure = originalExposure
}
}
this.renderer.render(this.scene, this.getActiveCamera())
const sceneData = this.renderer.domElement.toDataURL('image/png')

View File

@@ -100,23 +100,18 @@ export interface ViewHelperManagerInterface extends BaseManager {
}
export interface PreviewManagerInterface extends BaseManager {
previewRenderer: THREE.WebGLRenderer | null
previewCamera: THREE.Camera
previewContainer: HTMLDivElement
showPreview: boolean
previewWidth: number
createCapturePreview(container: Element | HTMLElement): void
updatePreviewSize(): void
updatePreviewRender(): void
togglePreview(showPreview: boolean): void
setTargetSize(width: number, height: number): void
handleResize(): void
updateBackgroundTexture(texture: THREE.Texture | null): void
getPreviewViewport(): {
left: number
bottom: number
width: number
height: number
} | null
renderPreview(): void
}
export interface EventManagerInterface {

View File

@@ -5,7 +5,6 @@ import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { t } from '@/i18n'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
@@ -13,6 +12,8 @@ import { useToastStore } from '@/stores/toastStore'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
type FolderType = 'input' | 'output' | 'temp'
function splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
@@ -27,7 +28,7 @@ function splitFilePath(path: string): [string, string] {
function getResourceURL(
subfolder: string,
filename: string,
type: ResultItemType = 'input'
type: FolderType = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Fit view to selected nodes"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Move Selected Nodes Down"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Move Selected Nodes Left"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Move Selected Nodes Right"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Move Selected Nodes Up"
},
"Comfy_Canvas_ResetView": {
"label": "Reset View"
},
@@ -147,10 +135,10 @@
"label": "Load Default Workflow"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Toggle the Custom Nodes Manager"
"label": "Custom Nodes Manager"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle the Custom Nodes Manager Progress Bar"
"label": "Toggle Progress Dialog"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"

View File

@@ -30,7 +30,6 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"help": "Help",
"loading": "Loading",
"findIssues": "Find Issues",
"reportIssue": "Send Report",
@@ -161,7 +160,6 @@
"lastUpdated": "Last Updated",
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes",
"packsSelected": "Packs Selected",
"status": {
"active": "Active",
@@ -515,8 +513,7 @@
"3D": "3D",
"Audio": "Audio",
"Image API": "Image API",
"Video API": "Video API",
"All": "All Templates"
"Video API": "Video API"
},
"templateDescription": {
"Basics": {
@@ -795,10 +792,6 @@
"Browse Templates": "Browse Templates",
"Delete Selected Items": "Delete Selected Items",
"Fit view to selected nodes": "Fit view to selected nodes",
"Move Selected Nodes Down": "Move Selected Nodes Down",
"Move Selected Nodes Left": "Move Selected Nodes Left",
"Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
@@ -829,8 +822,8 @@
"ComfyUI Issues": "ComfyUI Issues",
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Custom Nodes Manager": "Custom Nodes Manager",
"Toggle Progress Dialog": "Toggle Progress Dialog",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"New": "New",
"Clipspace": "Clipspace",
@@ -1430,13 +1423,5 @@
"cancelEditTooltip": "Cancel edit",
"copiedTooltip": "Copied",
"copyTooltip": "Copy message to clipboard"
},
"nodeHelpPage": {
"inputs": "Inputs",
"outputs": "Outputs",
"type": "Type",
"moreHelp": "For more help, visit the",
"documentationPage": "documentation page",
"loadError": "Failed to load help: {error}"
}
}

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Ajustar vista a los nodos seleccionados"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Mover nodos seleccionados hacia abajo"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Mover nodos seleccionados a la izquierda"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Mover nodos seleccionados a la derecha"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Mover nodos seleccionados hacia arriba"
},
"Comfy_Canvas_ResetView": {
"label": "Restablecer vista"
},

View File

@@ -294,7 +294,6 @@
"findIssues": "Encontrar problemas",
"firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menú > Usar nuevo menú > Desactivado\" para restaurar la antigua interfaz.",
"goToNode": "Ir al nodo",
"help": "Ayuda",
"icon": "Icono",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageUrl": "URL de la imagen",
@@ -586,7 +585,6 @@
},
"inWorkflow": "En Flujo de Trabajo",
"infoPanelEmpty": "Haz clic en un elemento para ver la información",
"installAllMissingNodes": "Instalar todos los nodos faltantes",
"installSelected": "Instalar Seleccionado",
"installationQueue": "Cola de Instalación",
"lastUpdated": "Última Actualización",
@@ -695,6 +693,7 @@
"ComfyUI Issues": "Problemas de ComfyUI",
"Contact Support": "Contactar soporte",
"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",
"Desktop User Guide": "Guía de usuario de escritorio",
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
@@ -709,10 +708,6 @@
"Interrupt": "Interrumpir",
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
"Manage group nodes": "Gestionar nodos de grupo",
"Move Selected Nodes Down": "Mover nodos seleccionados hacia abajo",
"Move Selected Nodes Left": "Mover nodos seleccionados hacia la izquierda",
"Move Selected Nodes Right": "Mover nodos seleccionados hacia la derecha",
"Move Selected Nodes Up": "Mover nodos seleccionados hacia arriba",
"Mute/Unmute Selected Nodes": "Silenciar/Activar sonido de nodos seleccionados",
"New": "Nuevo",
"Next Opened Workflow": "Siguiente flujo de trabajo abierto",
@@ -748,13 +743,12 @@
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
"Toggle Model Library Sidebar": "Alternar barra lateral de biblioteca de modelos",
"Toggle Node Library Sidebar": "Alternar barra lateral de biblioteca de nodos",
"Toggle Progress Dialog": "Alternar diálogo de progreso",
"Toggle Queue Sidebar": "Alternar barra lateral de cola",
"Toggle Search Box": "Alternar caja de búsqueda",
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
"Toggle Workflows Sidebar": "Alternar barra lateral de flujos de trabajo",
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
"Workflow": "Flujo de trabajo",
@@ -842,14 +836,6 @@
"video": "video",
"video_models": "modelos_de_video"
},
"nodeHelpPage": {
"documentationPage": "página de documentación",
"inputs": "Entradas",
"loadError": "Error al cargar la ayuda: {error}",
"moreHelp": "Para más ayuda, visita la",
"outputs": "Salidas",
"type": "Tipo"
},
"nodeTemplates": {
"enterName": "Introduzca el nombre",
"saveAsTemplate": "Guardar como plantilla"
@@ -1143,7 +1129,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Todas las plantillas",
"Area Composition": "Composición de Área",
"Audio": "Audio",
"Basics": "Básicos",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Ajuster la vue aux nœuds sélectionnés"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Déplacer les nœuds sélectionnés vers le bas"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Déplacer les nœuds sélectionnés vers la gauche"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Déplacer les nœuds sélectionnés vers la droite"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Déplacer les nœuds sélectionnés vers le haut"
},
"Comfy_Canvas_ResetView": {
"label": "Réinitialiser la vue"
},

View File

@@ -294,7 +294,6 @@
"findIssues": "Trouver des problèmes",
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
"goToNode": "Aller au nœud",
"help": "Aide",
"icon": "Icône",
"imageFailedToLoad": "Échec du chargement de l'image",
"imageUrl": "URL de l'image",
@@ -586,7 +585,6 @@
},
"inWorkflow": "Dans le flux de travail",
"infoPanelEmpty": "Cliquez sur un élément pour voir les informations",
"installAllMissingNodes": "Installer tous les nœuds manquants",
"installSelected": "Installer sélectionné",
"installationQueue": "File d'attente d'installation",
"lastUpdated": "Dernière mise à jour",
@@ -695,6 +693,7 @@
"ComfyUI Issues": "Problèmes de ComfyUI",
"Contact Support": "Contacter le support",
"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",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
@@ -709,10 +708,6 @@
"Interrupt": "Interrompre",
"Load Default Workflow": "Charger le flux de travail par défaut",
"Manage group nodes": "Gérer les nœuds de groupe",
"Move Selected Nodes Down": "Déplacer les nœuds sélectionnés vers le bas",
"Move Selected Nodes Left": "Déplacer les nœuds sélectionnés vers la gauche",
"Move Selected Nodes Right": "Déplacer les nœuds sélectionnés vers la droite",
"Move Selected Nodes Up": "Déplacer les nœuds sélectionnés vers le haut",
"Mute/Unmute Selected Nodes": "Mettre en sourdine/Activer le son des nœuds sélectionnés",
"New": "Nouveau",
"Next Opened Workflow": "Prochain flux de travail ouvert",
@@ -748,13 +743,12 @@
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Basculer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Basculer la barre latérale de la bibliothèque de nœuds",
"Toggle Progress Dialog": "Basculer la boîte de dialogue de progression",
"Toggle Queue Sidebar": "Basculer la barre latérale de la file d'attente",
"Toggle Search Box": "Basculer la boîte de recherche",
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle Workflows Sidebar": "Basculer la barre latérale des flux de travail",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
"Workflow": "Flux de travail",
@@ -842,14 +836,6 @@
"video": "vidéo",
"video_models": "modèles_vidéo"
},
"nodeHelpPage": {
"documentationPage": "page de documentation",
"inputs": "Entrées",
"loadError": "Échec du chargement de laide : {error}",
"moreHelp": "Pour plus d'aide, visitez la",
"outputs": "Sorties",
"type": "Type"
},
"nodeTemplates": {
"enterName": "Entrez le nom",
"saveAsTemplate": "Enregistrer comme modèle"
@@ -1143,7 +1129,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Tous les modèles",
"Area Composition": "Composition de zone",
"Audio": "Audio",
"Basics": "Basiques",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "選択したノードにビューを合わせる"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "選択したノードを下に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "選択したノードを左に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "選択したノードを右に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "選択したノードを上に移動"
},
"Comfy_Canvas_ResetView": {
"label": "ビューをリセット"
},

View File

@@ -294,7 +294,6 @@
"findIssues": "問題を見つける",
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
"goToNode": "ノードに移動",
"help": "ヘルプ",
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imageUrl": "画像URL",
@@ -586,7 +585,6 @@
},
"inWorkflow": "ワークフロー内",
"infoPanelEmpty": "アイテムをクリックして情報を表示します",
"installAllMissingNodes": "すべての不足しているノードをインストール",
"installSelected": "選択したものをインストール",
"installationQueue": "インストールキュー",
"lastUpdated": "最終更新日",
@@ -695,6 +693,7 @@
"ComfyUI Issues": "ComfyUIの問題",
"Contact Support": "サポートに連絡",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
@@ -709,10 +708,6 @@
"Interrupt": "中断",
"Load Default Workflow": "デフォルトワークフローを読み込む",
"Manage group nodes": "グループノードを管理",
"Move Selected Nodes Down": "選択したノードを下へ移動",
"Move Selected Nodes Left": "選択したノードを左へ移動",
"Move Selected Nodes Right": "選択したノードを右へ移動",
"Move Selected Nodes Up": "選択したノードを上へ移動",
"Mute/Unmute Selected Nodes": "選択したノードのミュート/ミュート解除",
"New": "新規",
"Next Opened Workflow": "次に開いたワークフロー",
@@ -748,13 +743,12 @@
"Toggle Logs Bottom Panel": "ログパネル下部を切り替え",
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
"Toggle Node Library Sidebar": "ノードライブラリサイドバーを切り替え",
"Toggle Progress Dialog": "進行状況ダイアログの切り替え",
"Toggle Queue Sidebar": "キューサイドバーを切り替え",
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
"Undo": "元に戻す",
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
"Workflow": "ワークフロー",
@@ -842,14 +836,6 @@
"video": "ビデオ",
"video_models": "ビデオモデル"
},
"nodeHelpPage": {
"documentationPage": "ドキュメントページ",
"inputs": "入力",
"loadError": "ヘルプの読み込みに失敗しました: {error}",
"moreHelp": "さらに詳しい情報は、",
"outputs": "出力",
"type": "タイプ"
},
"nodeTemplates": {
"enterName": "名前を入力",
"saveAsTemplate": "テンプレートとして保存"
@@ -1143,7 +1129,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "すべてのテンプレート",
"Area Composition": "エリア構成",
"Audio": "オーディオ",
"Basics": "基本",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "선택한 노드에 뷰 맞추기"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "선택한 노드 아래로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "선택한 노드 왼쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "선택한 노드 오른쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "선택한 노드 위로 이동"
},
"Comfy_Canvas_ResetView": {
"label": "뷰 재설정"
},

View File

@@ -294,7 +294,6 @@
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"goToNode": "노드로 이동",
"help": "도움말",
"icon": "아이콘",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"imageUrl": "이미지 URL",
@@ -586,7 +585,6 @@
},
"inWorkflow": "워크플로우 내",
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
"installAllMissingNodes": "모든 누락된 노드 설치",
"installSelected": "선택한 항목 설치",
"installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트",
@@ -695,6 +693,7 @@
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Contact Support": "고객 지원 문의",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
@@ -709,10 +708,6 @@
"Interrupt": "중단",
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
"Move Selected Nodes Up": "선택한 노드 위로 이동",
"Mute/Unmute Selected Nodes": "선택한 노드 활성화/비활성화",
"New": "새로 만들기",
"Next Opened Workflow": "다음 열린 워크플로",
@@ -748,13 +743,12 @@
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Progress Dialog": "진행 상황 대화 상자 전환",
"Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
"Workflow": "워크플로",
@@ -842,14 +836,6 @@
"video": "비디오",
"video_models": "비디오 모델"
},
"nodeHelpPage": {
"documentationPage": "문서 페이지",
"inputs": "입력",
"loadError": "도움말을 불러오지 못했습니다: {error}",
"moreHelp": "더 많은 도움말은",
"outputs": "출력",
"type": "유형"
},
"nodeTemplates": {
"enterName": "이름 입력",
"saveAsTemplate": "템플릿으로 저장"
@@ -1143,7 +1129,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "모든 템플릿",
"Area Composition": "영역 구성",
"Audio": "오디오",
"Basics": "기본",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Подогнать вид к выбранным нодам"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Переместить выбранные узлы вниз"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Переместить выбранные узлы влево"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Переместить выбранные узлы вправо"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Переместить выбранные узлы вверх"
},
"Comfy_Canvas_ResetView": {
"label": "Сбросить вид"
},

View File

@@ -294,7 +294,6 @@
"findIssues": "Найти проблемы",
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
"goToNode": "Перейти к ноде",
"help": "Помощь",
"icon": "Иконка",
"imageFailedToLoad": "Не удалось загрузить изображение",
"imageUrl": "URL изображения",
@@ -586,7 +585,6 @@
},
"inWorkflow": "В рабочем процессе",
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
"installAllMissingNodes": "Установить все отсутствующие узлы",
"installSelected": "Установить выбранное",
"installationQueue": "Очередь установки",
"lastUpdated": "Последнее обновление",
@@ -695,6 +693,7 @@
"ComfyUI Issues": "Проблемы ComfyUI",
"Contact Support": "Связаться с поддержкой",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
@@ -709,10 +708,6 @@
"Interrupt": "Прервать",
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
"Manage group nodes": "Управление групповыми нодами",
"Move Selected Nodes Down": "Переместить выбранные узлы вниз",
"Move Selected Nodes Left": "Переместить выбранные узлы влево",
"Move Selected Nodes Right": "Переместить выбранные узлы вправо",
"Move Selected Nodes Up": "Переместить выбранные узлы вверх",
"Mute/Unmute Selected Nodes": "Отключить/включить звук для выбранных нод",
"New": "Новый",
"Next Opened Workflow": "Следующий открытый рабочий процесс",
@@ -748,13 +743,12 @@
"Toggle Logs Bottom Panel": "Переключение нижней панели журналов",
"Toggle Model Library Sidebar": "Переключение боковой панели библиотеки моделей",
"Toggle Node Library Sidebar": "Переключение боковой панели библиотеки нод",
"Toggle Progress Dialog": "Переключить диалоговое окно прогресса",
"Toggle Queue Sidebar": "Переключение боковой панели очереди",
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
"Toggle Workflows Sidebar": "Переключение боковой панели рабочих процессов",
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
"Ungroup selected group nodes": "Разгруппировать выбранные групповые ноды",
"Workflow": "Рабочий процесс",
@@ -842,14 +836,6 @@
"video": "видео",
"video_models": "видеомодели"
},
"nodeHelpPage": {
"documentationPage": "страницу документации",
"inputs": "Входы",
"loadError": "Не удалось загрузить справку: {error}",
"moreHelp": "Для получения дополнительной помощи посетите",
"outputs": "Выходы",
"type": "Тип"
},
"nodeTemplates": {
"enterName": "Введите название",
"saveAsTemplate": "Сохранить как шаблон"
@@ -1143,7 +1129,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Все шаблоны",
"Area Composition": "Композиция области",
"Audio": "Аудио",
"Basics": "Основы",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "适应视图到选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "下移选中的节点"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "向左移动选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "向右移动选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "上移选中的节点"
},
"Comfy_Canvas_ResetView": {
"label": "重置视图"
},

View File

@@ -294,7 +294,6 @@
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"goToNode": "转到节点",
"help": "帮助",
"icon": "图标",
"imageFailedToLoad": "图像加载失败",
"imageUrl": "图片网址",
@@ -586,7 +585,6 @@
},
"inWorkflow": "在工作流中",
"infoPanelEmpty": "点击一个项目查看信息",
"installAllMissingNodes": "安装所有缺失节点",
"installSelected": "安装选定",
"installationQueue": "安装队列",
"lastUpdated": "最后更新",
@@ -695,6 +693,7 @@
"ComfyUI Issues": "ComfyUI 问题",
"Contact Support": "联系支持",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
@@ -709,10 +708,6 @@
"Interrupt": "中断",
"Load Default Workflow": "加载默认工作流",
"Manage group nodes": "管理组节点",
"Move Selected Nodes Down": "下移所选节点",
"Move Selected Nodes Left": "左移所选节点",
"Move Selected Nodes Right": "右移所选节点",
"Move Selected Nodes Up": "上移所选节点",
"Mute/Unmute Selected Nodes": "静音/取消静音选定节点",
"New": "新建",
"Next Opened Workflow": "下一个打开的工作流",
@@ -748,13 +743,12 @@
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切换模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Progress Dialog": "切换进度对话框",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle Workflows Sidebar": "切换工作流侧边栏",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
"Workflow": "工作流",
@@ -842,14 +836,6 @@
"video": "视频",
"video_models": "视频模型"
},
"nodeHelpPage": {
"documentationPage": "文档页面",
"inputs": "输入",
"loadError": "加载帮助失败:{error}",
"moreHelp": "如需更多帮助,请访问",
"outputs": "输出",
"type": "类型"
},
"nodeTemplates": {
"enterName": "输入名称",
"saveAsTemplate": "另存为模板"
@@ -1143,7 +1129,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "所有模板",
"Area Composition": "区域组成",
"Audio": "音频",
"Basics": "基础",

View File

@@ -11,13 +11,10 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
const zNodeType = z.string()
const zQueueIndex = z.number()
const zPromptId = z.string()
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>
const zResultItem = z.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional()
type: z.string().optional()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z

View File

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

View File

@@ -1,8 +1,6 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { resultItemType } from '@/schemas/apiSchema'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
@@ -74,7 +72,7 @@ export const zStringInputOptions = zBaseInputOptions.extend({
export const zComboInputOptions = zBaseInputOptions.extend({
control_after_generate: z.boolean().optional(),
image_upload: z.boolean().optional(),
image_folder: resultItemType.optional(),
image_folder: z.enum(['input', 'output', 'temp']).optional(),
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
animated_image_upload: z.boolean().optional(),
@@ -221,7 +219,6 @@ export const zComfyNodeDef = z.object({
name: z.string(),
display_name: z.string(),
description: z.string(),
help: z.string().optional(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
@@ -230,7 +227,7 @@ export const zComfyNodeDef = z.object({
/**
* Whether the node is an API node. Running API nodes requires login to
* Comfy Org account.
* https://docs.comfy.org/tutorials/api-nodes/overview
* https://www.comfy.org/faq
*/
api_node: z.boolean().optional()
})

View File

@@ -67,7 +67,6 @@ import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
@@ -1038,10 +1037,8 @@ export class ComfyApp {
}
// Collect models metadata from node
const selectedModels = getSelectedModelsMetadata(n)
if (selectedModels?.length) {
embeddedModels.push(...selectedModels)
}
if (n.properties?.models?.length)
embeddedModels.push(...n.properties.models)
}
// Merge models from the workflow's root-level 'models' field
@@ -1555,6 +1552,7 @@ export class ComfyApp {
/**
* Registers a Comfy web extension with the app
* @param {ComfyExtension} extension
* @deprecated Use useExtensionService().registerExtension instead
*/
registerExtension(extension: ComfyExtension) {
useExtensionService().registerExtension(extension)

View File

@@ -0,0 +1,245 @@
import QuickLRU from '@alloc/quick-lru'
import type {
BaseSearchParamsWithoutQuery,
Hit,
SearchQuery,
SearchResponse
} from 'algoliasearch/dist/lite/browser'
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { omit } from 'lodash'
import { components } from '@/types/comfyRegistryTypes'
import { paramsToCacheKey } from '@/utils/formatUtil'
const DEFAULT_MAX_CACHE_SIZE = 64
const DEFAULT_MIN_CHARS_FOR_SUGGESTIONS = 2
type SafeNestedProperty<
T,
K1 extends keyof T,
K2 extends keyof NonNullable<T[K1]>
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
type RegistryNodePack = components['schemas']['Node']
type SearchPacksResult = {
nodePacks: Hit<AlgoliaNodePack>[]
querySuggestions: Hit<NodesIndexSuggestion>[]
}
export interface AlgoliaNodePack {
objectID: RegistryNodePack['id']
name: RegistryNodePack['name']
publisher_id: SafeNestedProperty<RegistryNodePack, 'publisher', 'id'>
description: RegistryNodePack['description']
comfy_nodes: string[]
total_install: RegistryNodePack['downloads']
id: RegistryNodePack['id']
create_time: string
update_time: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'createdAt'
>
license: RegistryNodePack['license']
repository_url: RegistryNodePack['repository']
status: RegistryNodePack['status']
latest_version: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'version'
>
latest_version_status: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'status'
>
comfy_node_extract_status: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'comfy_node_extract_status'
>
icon_url: RegistryNodePack['icon']
}
export type SearchAttribute = keyof AlgoliaNodePack
const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
'comfy_nodes',
'name',
'description',
'latest_version',
'status',
'publisher_id',
'total_install',
'create_time',
'update_time',
'license',
'repository_url',
'latest_version_status',
'comfy_node_extract_status',
'id',
'icon_url'
]
export interface NodesIndexSuggestion {
nb_words: number
nodes_index: {
exact_nb_hits: number
facets: {
exact_matches: Record<string, number>
analytics: Record<string, any>
}
}
objectID: RegistryNodePack['id']
popularity: number
query: string
}
type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
pageSize: number
pageNumber: number
restrictSearchableAttributes: SearchAttribute[]
}
interface AlgoliaSearchServiceOptions {
/**
* Maximum number of search results to store in the cache.
* The cache is automatically cleared when the component is unmounted.
* @default 64
*/
maxCacheSize?: number
/**
* Minimum number of characters for suggestions. An additional query
* will be made to the suggestions/completions index for queries that
* are this length or longer.
* @default 3
*/
minCharsForSuggestions?: number
}
export const useAlgoliaSearchService = (
options: AlgoliaSearchServiceOptions = {}
) => {
const {
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
} = options
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
maxSize: maxCacheSize
})
const toRegistryLatestVersion = (
algoliaNode: AlgoliaNodePack
): RegistryNodePack['latest_version'] => {
return {
version: algoliaNode.latest_version,
createdAt: algoliaNode.update_time,
status: algoliaNode.latest_version_status,
comfy_node_extract_status:
algoliaNode.comfy_node_extract_status ?? undefined
}
}
const toRegistryPublisher = (
algoliaNode: AlgoliaNodePack
): RegistryNodePack['publisher'] => {
return {
id: algoliaNode.publisher_id,
name: algoliaNode.publisher_id
}
}
/**
* Convert from node pack in Algolia format to Comfy Registry format
*/
function toRegistryPack(algoliaNode: AlgoliaNodePack): RegistryNodePack {
return {
id: algoliaNode.id ?? algoliaNode.objectID,
name: algoliaNode.name,
description: algoliaNode.description,
repository: algoliaNode.repository_url,
license: algoliaNode.license,
downloads: algoliaNode.total_install,
status: algoliaNode.status,
icon: algoliaNode.icon_url,
latest_version: toRegistryLatestVersion(algoliaNode),
publisher: toRegistryPublisher(algoliaNode),
// @ts-expect-error remove when comfy_nodes is added to node (pack) info
comfy_nodes: algoliaNode.comfy_nodes
}
}
/**
* Search for node packs in Algolia
*/
const searchPacks = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const { pageSize, pageNumber } = params
const rest = omit(params, ['pageSize', 'pageNumber'])
const requests: SearchQuery[] = [
{
query,
indexName: 'nodes_index',
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
...rest,
hitsPerPage: pageSize,
page: pageNumber
}
]
const shouldQuerySuggestions = query.length >= minCharsForSuggestions
// If the query is long enough, also query the suggestions index
if (shouldQuerySuggestions) {
requests.push({
indexName: 'nodes_index_query_suggestions',
query
})
}
const { results } = await searchClient.search<
AlgoliaNodePack | NodesIndexSuggestion
>({
requests,
strategy: 'none'
})
const [nodePacks, querySuggestions = { hits: [] }] = results as [
SearchResponse<AlgoliaNodePack>,
SearchResponse<NodesIndexSuggestion>
]
return {
nodePacks: nodePacks.hits,
querySuggestions: querySuggestions.hits
}
}
const searchPacksCached = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const cacheKey = paramsToCacheKey({ query, ...params })
const cachedResult = searchPacksCache.get(cacheKey)
if (cachedResult !== undefined) return cachedResult
const result = await searchPacks(query, params)
searchPacksCache.set(cacheKey, result)
return result
}
const clearSearchPacksCache = () => {
searchPacksCache.clear()
}
return {
searchPacks,
searchPacksCached,
toRegistryPack,
clearSearchPacksCache
}
}

View File

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

View File

@@ -21,6 +21,7 @@ import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkfl
import { t } from '@/i18n'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { type ShowDialogOptions, useDialogStore } from '@/stores/dialogStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
export type ConfirmationDialogType =
| 'default'
@@ -128,14 +129,16 @@ export const useDialogService = () => {
}
function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
props: InstanceType<typeof ManagerDialogContent>['$props'] = {
initialTab: ManagerTab.All
}
) {
dialogStore.showDialog({
key: 'global-manager',
component: ManagerDialogContent,
headerComponent: ManagerHeader,
dialogComponentProps: {
closable: true,
closable: false,
pt: {
header: { class: '!p-0 !m-0' },
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
@@ -394,26 +397,6 @@ export const useDialogService = () => {
}
}
function toggleManagerDialog(
props?: InstanceType<typeof ManagerDialogContent>['$props']
) {
if (dialogStore.isDialogOpen('global-manager')) {
dialogStore.closeDialog({ key: 'global-manager' })
} else {
showManagerDialog(props)
}
}
function toggleManagerProgressDialog(
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
) {
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
} else {
showManagerProgressDialog({ props })
}
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -431,8 +414,6 @@ export const useDialogService = () => {
showUpdatePasswordDialog,
showExtensionDialog,
prompt,
confirm,
toggleManagerDialog,
toggleManagerProgressDialog
confirm
}
}

View File

@@ -1,227 +0,0 @@
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
SearchPacksResult
} from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
interface ProviderState {
provider: NodePackSearchProvider
name: string
isHealthy: boolean
lastError?: Error
lastAttempt?: Date
consecutiveFailures: number
}
const CIRCUIT_BREAKER_THRESHOLD = 3 // Number of failures before circuit opens
const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
/**
* API Gateway for registry search providers with circuit breaker pattern.
* Acts as a single entry point that routes search requests to appropriate providers
* and handles failures gracefully by falling back to alternative providers.
*
* Implements:
* - Gateway pattern: Single entry point for all search requests
* - Circuit breaker: Prevents repeated calls to failed services
* - Automatic failover: Cascades through providers on failure
*/
export const useRegistrySearchGateway = (): NodePackSearchProvider => {
const providers: ProviderState[] = []
let activeProviderIndex = 0
// Initialize providers in priority order
try {
providers.push({
provider: useAlgoliaSearchProvider(),
name: 'Algolia',
isHealthy: true,
consecutiveFailures: 0
})
} catch (error) {
console.warn('Failed to initialize Algolia provider:', error)
}
providers.push({
provider: useComfyRegistrySearchProvider(),
name: 'ComfyRegistry',
isHealthy: true,
consecutiveFailures: 0
})
// TODO: Add an "offline" provider that operates on a local cache of the registry.
/**
* Check if a provider's circuit breaker should be closed (available to try)
*/
const isCircuitClosed = (providerState: ProviderState): boolean => {
if (providerState.consecutiveFailures < CIRCUIT_BREAKER_THRESHOLD) {
return true
}
// Check if enough time has passed to retry
if (providerState.lastAttempt) {
const timeSinceLastAttempt =
Date.now() - providerState.lastAttempt.getTime()
if (timeSinceLastAttempt > CIRCUIT_BREAKER_TIMEOUT) {
console.info(
`Retrying ${providerState.name} provider after circuit breaker timeout`
)
return true
}
}
return false
}
/**
* Record a successful call to a provider
*/
const recordSuccess = (providerState: ProviderState) => {
providerState.isHealthy = true
providerState.consecutiveFailures = 0
providerState.lastError = undefined
}
/**
* Record a failed call to a provider
*/
const recordFailure = (providerState: ProviderState, error: Error) => {
providerState.consecutiveFailures++
providerState.lastError = error
providerState.lastAttempt = new Date()
if (providerState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
providerState.isHealthy = false
console.warn(
`${providerState.name} provider circuit breaker opened after ${providerState.consecutiveFailures} failures`
)
}
}
/**
* Get the currently active provider based on circuit breaker states
*/
const getActiveProvider = (): NodePackSearchProvider => {
// First, try to use the current active provider if it's healthy
const currentProvider = providers[activeProviderIndex]
if (currentProvider && isCircuitClosed(currentProvider)) {
return currentProvider.provider
}
// Otherwise, find the first healthy provider
for (let i = 0; i < providers.length; i++) {
const providerState = providers[i]
if (isCircuitClosed(providerState)) {
activeProviderIndex = i
return providerState.provider
}
}
throw new Error('No available search providers')
}
/**
* Update the active provider index after a failure.
* Move to the next provider if available.
*/
const updateActiveProviderOnFailure = () => {
if (activeProviderIndex < providers.length - 1) {
activeProviderIndex++
}
}
/**
* Search for node packs.
*/
const searchPacks = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
let lastError: Error | null = null
// Start with the current active provider
for (let attempts = 0; attempts < providers.length; attempts++) {
try {
const provider = getActiveProvider()
const providerState = providers[activeProviderIndex]
const result = await provider.searchPacks(query, params)
recordSuccess(providerState)
return result
} catch (error) {
lastError = error as Error
const providerState = providers[activeProviderIndex]
recordFailure(providerState, lastError)
console.warn(
`${providerState.name} search provider failed (${providerState.consecutiveFailures} failures):`,
error
)
// Try the next provider
updateActiveProviderOnFailure()
}
}
// If we get here, all providers failed
throw new Error(
`All search providers failed. Last error: ${lastError?.message || 'Unknown error'}`
)
}
/**
* Clear the search cache for all providers that implement it.
*/
const clearSearchCache = () => {
for (const providerState of providers) {
try {
providerState.provider.clearSearchCache()
} catch (error) {
console.warn(
`Failed to clear cache for ${providerState.name} provider:`,
error
)
}
}
}
/**
* Get the sort value for a pack.
* @example
* const pack = {
* id: '123',
* name: 'Test Pack',
* downloads: 100
* }
* const sortValue = getSortValue(pack, 'downloads')
* console.log(sortValue) // 100
*/
const getSortValue = (
pack: RegistryNodePack,
sortField: string
): string | number => {
return getActiveProvider().getSortValue(pack, sortField)
}
/**
* Get the sortable fields for the active provider.
* @example
* const sortableFields = getSortableFields()
* console.log(sortableFields) // ['downloads', 'created', 'updated', 'publisher', 'name']
*/
const getSortableFields = () => {
return getActiveProvider().getSortableFields()
}
return {
searchPacks,
clearSearchCache,
getSortValue,
getSortableFields
}
}

Some files were not shown because too many files have changed in this diff Show More