Compare commits

...

44 Commits

Author SHA1 Message Date
bymyself
9d0fee44e3 feat: add API node pricing display in node badges
- Adds pricing data for API nodes from various providers
- Displays pricing in the credits badge next to API nodes
- Implements pricing display in gold badge color to distinguish costs
2025-05-17 23:01:57 -07:00
bymyself
4804f6d62f Add READMEs for major folders 2025-05-17 22:40:24 -07:00
Christian Byrne
e76e9ec61a docs: enhance README with development setup and troubleshooting guides (#3920) 2025-05-17 17:15:10 -04:00
filtered
94fde504d0 [CI] Add dev release GH Action (#3910) 2025-05-17 12:43:01 +10:00
Comfy Org PR Bot
e3ecf90bb3 1.20.2 (#3917)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-16 22:02:09 -04:00
Yoland Yan
a131f36cf3 [Fix] Fix out of bound issue when window was close and reopen at diff size (#3906) 2025-05-16 22:01:30 -04:00
Christian Byrne
4cad1a9567 Add LLM chat history widget (#3907)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-16 22:00:45 -04:00
filtered
47a6c6d595 [Dev] Allow Vue dev tools to be disabled (#3911) 2025-05-16 21:59:23 -04:00
filtered
068279ec34 Replace reactive DragAndScale proxy with callback (#3915) 2025-05-16 21:58:21 -04:00
Comfy Org PR Bot
2885ebf5e0 [chore] Update litegraph to 0.15.11 (#3914)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-17 02:03:58 +10:00
Yoland Yan
d4e76ddc45 Add vite-plugin-html and vite-plugin-vue-devtools (#3903) 2025-05-15 14:51:39 -04:00
Chenlei Hu
9a5b80a279 [Refactor] Split SelectionToolbox buttons to components (#3902) 2025-05-15 11:20:51 -04:00
filtered
985dab7e1c Allow LGraph.configure to be made recursive (#3894) 2025-05-15 10:48:56 -04:00
filtered
7f2b8a5321 [CodeHealth] Add various minor fixes - logging, missed i18n (#3895)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-15 22:04:48 +10:00
filtered
59ce169ec9 Add selection changed state watcher (#3899) 2025-05-15 21:13:54 +10:00
Comfy Org PR Bot
4294b2c13b [chore] Update litegraph to 0.15.10 (#3898)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-15 20:22:01 +10:00
Comfy Org PR Bot
242c7e2885 1.20.1 (#3891)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-14 22:40:07 -04:00
Chenlei Hu
c1442ec755 [Cleanup] Remove api nodes news dialog (#3890)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-14 21:06:08 -04:00
Terry Jia
ebd9c96a28 [3d] bug fix and add loading for background image change (#3888) 2025-05-14 16:11:36 -04:00
Chenlei Hu
e6d649b596 [Refactor] Convert NodeBadge.vue to composable (#3883) 2025-05-13 21:56:26 -04:00
Comfy Org PR Bot
b037ba84e3 1.20.0 (#3850)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-05-13 21:05:21 -04:00
杨必赞
7c5c47c105 expose user loggedin in extensionManager (#3871) 2025-05-13 21:04:27 -04:00
Chenlei Hu
b152f67d95 [Refactor] useBrowserTabTitle composable (#3881) 2025-05-13 17:02:54 -04:00
Chenlei Hu
be84d81c32 [Branding] Show execution progress in favicon (#3880) 2025-05-13 15:57:18 -04:00
Christian Byrne
a474a094f3 [Manager] Fix search results render incorrectly when scrolling pages then changing query or tab (#3879) 2025-05-13 15:29:10 -04:00
Christian Byrne
bc360eef15 [Manager] Cache Algolia searches and limit suggestions queries (#3876) 2025-05-13 15:28:42 -04:00
Christian Byrne
a52cc0ebe9 [Manager] Don't show empty suggestions dropdown (#3878) 2025-05-13 11:40:15 -07:00
Christian Byrne
b3c6513e7a Fix bug: Virtual Grid increments page size when no results left to render (#3877) 2025-05-13 11:26:00 -07:00
Christian Byrne
a9bdc70e28 [API Node] Show message tip about API-key-based login (#3851)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <hcl@comfy.org>
2025-05-13 13:03:13 -04:00
filtered
58906fa821 [CodeHealth] Remove remaining uses of global app var (#3868) 2025-05-13 12:01:02 -04:00
filtered
a17fb04f83 [Test] Add per-workflow viewport comparison test (#3867)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-13 11:48:38 +10:00
Comfy Org PR Bot
5c0ad994d8 1.19.9 (#3866)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-12 16:25:19 -04:00
filtered
31be0a04f0 Use upstreamed viewport serialisation (#3864) 2025-05-13 05:33:10 +10:00
Comfy Org PR Bot
d9ab4270d1 [chore] Update litegraph to 0.15.9 (#3863)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-13 05:11:00 +10:00
filtered
36bd1f74ca [TS] Fix weak ds.offset type breaks litegraph CI (#3861) 2025-05-13 04:34:47 +10:00
filtered
7144ec54aa Fix UI crash when selecting broken node + TS fixes (#3859) 2025-05-12 17:57:59 +10:00
Dr.Lt.Data
b2f144c27b refine locales/ko (#3853) 2025-05-12 04:25:02 +10:00
Christian Byrne
014c0022c1 [API Node] Remove mailto on own address (#3852) 2025-05-11 11:12:54 +10:00
Comfy Org PR Bot
5d556c9c94 1.19.8 (#3849)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-10 18:18:54 -04:00
Christian Byrne
992c2ba822 Show text progress messages on executing nodes (#3824) 2025-05-10 16:10:58 -04:00
Christian Byrne
4cc6a15fde Fix refresh combos command fails on nodes using V2 spec (#3848) 2025-05-10 16:06:25 -04:00
Christian Byrne
3f50b8b46d [Test] Add missing plugins in component tests (#3847) 2025-05-10 16:05:30 -04:00
Christian Byrne
bb588ff44e [API Node] Use staging platform url while in dev environment (#3846) 2025-05-10 16:04:51 -04:00
Christian Byrne
974236ce5a Fix video previews not displayed if VHS previously installed but disabled or uninstalled (#3844) 2025-05-10 16:03:55 -04:00
117 changed files with 5413 additions and 1299 deletions

View File

@@ -25,3 +25,7 @@ ENABLE_MINIFY=true
# templates are served via the normal method from the server's python site
# packages.
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

72
.github/workflows/dev-release.yaml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Create Dev PyPI Package
on:
workflow_dispatch:
inputs:
devVersion:
description: 'Dev version'
required: true
type: number
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.current_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Get current version
id: current_version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Build project
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
USE_PROD_CONFIG: 'true'
run: |
npm ci
npm run build
npm run zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist-files
path: |
dist/
dist.zip
publish_pypi:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist-files
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install build dependencies
run: python -m pip install build
- name: Setup pypi package
run: |
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
- name: Build pypi package
run: python -m build
working-directory: comfyui_frontend_package
env:
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
- name: Publish pypi package
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:
with:
repository: 'Comfy-Org/ComfyUI_devtools'
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: '9d2421fd3208a310e4d0f71fca2ea0c985759c33'
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
- uses: actions/setup-node@v4
with:

135
README.md
View File

@@ -526,6 +526,38 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
## Development
### Prerequisites
- Node.js (v16 or later) and npm must be installed
- Git for version control
- A running ComfyUI backend instance
### Initial Setup
1. Clone the repository:
```bash
git clone https://github.com/Comfy-Org/ComfyUI_frontend.git
cd ComfyUI_frontend
```
2. Install dependencies:
```bash
npm install
```
3. Configure environment (optional):
Create a `.env` file in the project root based on the provided [.env.example](.env.example) file.
**Note about ports**: By default, the dev server expects the ComfyUI backend at `localhost:8188`. If your ComfyUI instance runs on a different port, update this in your `.env` file.
### Dev Server Configuration
To launch ComfyUI and have it connect to your development server:
```bash
python main.py --port 8188
```
### Tech Stack
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
@@ -547,7 +579,6 @@ core extensions will be loaded.
- Start local ComfyUI backend at `localhost:8188`
- Run `npm run dev` to start the dev server
- Run `npm run dev:electron` to start the dev server with electron API mocked
#### Access dev server on touch devices
@@ -575,7 +606,7 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
This project includes `.vscode/launch.json.default` and `.vscode/settings.json.default` files with recommended launch and workspace settings for editors that use the `.vscode` directory (e.g., VS Code, Cursor, etc.).
Weve 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.
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.
### Unit Test
@@ -606,3 +637,103 @@ This will replace the litegraph package in this repo with the local litegraph re
### i18n
See [locales/README.md](src/locales/README.md) for details.
## Troubleshooting
> **Note**: For comprehensive troubleshooting and how-to guides, please refer to our [official documentation](https://docs.comfy.org/). This section covers only the most common issues related to frontend development.
> **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

@@ -0,0 +1,36 @@
{
"id": "5635564e-189f-49e4-9b25-6b7634bcd595",
"revision": 0,
"last_node_id": 78,
"last_link_id": 53,
"nodes": [
{
"id": 78,
"type": "DevToolsNodeWithV2ComboInput",
"pos": [1320, 904],
"size": [270.3199157714844, 58],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "COMBO",
"type": "COMBO",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsNodeWithV2ComboInput"
},
"widgets_values": ["A"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.19.7"
},
"version": 0.4
}

View File

@@ -133,6 +133,9 @@ export class ComfyPage {
// Inputs
public readonly workflowUploadInput: Locator
// Toasts
public readonly visibleToasts: Locator
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
@@ -159,6 +162,8 @@ export class ComfyPage {
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.workflowUploadInput = page.locator('#comfy-file-input')
this.visibleToasts = page.locator('.p-toast-message:visible')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
@@ -270,7 +275,6 @@ export class ComfyPage {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
localStorage.setItem('api-nodes-news-seen', 'true')
}, this.id)
}
await this.goto()
@@ -397,6 +401,30 @@ export class ComfyPage {
await this.nextFrame()
}
async deleteWorkflow(
workflowName: string,
whenMissing: 'ignoreMissing' | 'throwIfMissing' = 'ignoreMissing'
) {
// Open workflows tab
const { workflowsTab } = this.menu
await workflowsTab.open()
// Action to take if workflow missing
if (whenMissing === 'ignoreMissing') {
const workflows = await workflowsTab.getTopLevelSavedWorkflowNames()
if (!workflows.includes(workflowName)) return
}
// Delete workflow
await workflowsTab.getPersistedItem(workflowName).click({ button: 'right' })
await this.clickContextMenuItem('Delete')
await this.confirmDialog.delete.click()
// Clear toast & close tab
await this.closeToasts(1)
await workflowsTab.close()
}
async resetView() {
if (await this.resetViewButton.isVisible()) {
await this.resetViewButton.click()
@@ -413,7 +441,20 @@ export class ComfyPage {
}
async getVisibleToastCount() {
return await this.page.locator('.p-toast-message:visible').count()
return await this.visibleToasts.count()
}
async closeToasts(requireCount = 0) {
if (requireCount) await expect(this.visibleToasts).toHaveCount(requireCount)
// Clear all toasts
const toastCloseButtons = await this.page
.locator('.p-toast-close-button')
.all()
for (const button of toastCloseButtons) {
await button.click()
}
await expect(this.visibleToasts).toHaveCount(0)
}
async clickTextEncodeNode1() {

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -689,3 +689,42 @@ test.describe('Load duplicate workflow', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(1)
})
})
test.describe('Viewport settings', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
await comfyPage.setupWorkflowsDirectory({})
})
test('Keeps viewport settings when changing tabs', async ({
comfyPage,
comfyMouse
}) => {
// Screenshot the canvas element
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
// Save workflow as a new file, then zoom out before screen shot
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
await comfyMouse.move(comfyPage.emptySpace)
for (let i = 0; i < 4; i++) {
await comfyMouse.wheel(0, 60)
}
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
// Go back to Workflow A
await tabA.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
// And back to Workflow B
await tabB.click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -53,6 +53,26 @@ test.describe('Combo text widget', () => {
const refreshedComboValues = await getComboValues()
expect(refreshedComboValues).not.toEqual(initialComboValues)
})
test('Should refresh combo values of nodes with v2 combo input spec', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('node_with_v2_combo_input')
// click canvas to focus
await comfyPage.page.mouse.click(400, 300)
// press R to trigger refresh
await comfyPage.page.keyboard.press('r')
// wait for nodes' widgets to be updated
await comfyPage.page.mouse.click(400, 300)
await comfyPage.nextFrame()
// get the combo widget's values
const comboValues = await comfyPage.page.evaluate(() => {
return window['app'].graph.nodes
.find((node) => node.title === 'Node With V2 Combo Input')
.widgets.find((widget) => widget.name === 'combo_input').options.values
})
expect(comboValues).toEqual(['A', 'B'])
})
})
test.describe('Boolean widget', () => {

1719
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.19.7",
"version": "1.20.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -63,6 +63,8 @@
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.19",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
@@ -72,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.8",
"@comfyorg/litegraph": "^0.15.11",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

View File

@@ -1,58 +0,0 @@
<template>
<div>
<!-- This component does not render anything visible. -->
</div>
</template>
<script setup lang="ts">
import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
const DEFAULT_TITLE = 'ComfyUI'
const TITLE_SUFFIX = ' - ComfyUI'
const executionStore = useExecutionStore()
const executionText = computed(() =>
executionStore.isIdle
? ''
: `[${Math.round(executionStore.executionProgress * 100)}%]`
)
const settingStore = useSettingStore()
const newMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const workflowStore = useWorkflowStore()
const isUnsavedText = computed(() =>
workflowStore.activeWorkflow?.isModified ||
!workflowStore.activeWorkflow?.isPersisted
? ' *'
: ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.filename
return workflowName
? isUnsavedText.value + workflowName + TITLE_SUFFIX
: DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
: ''
)
const workflowTitle = computed(
() =>
executionText.value +
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
)
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
useTitle(title)
</script>

View File

@@ -63,15 +63,6 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
if (x.value !== 0 || y.value !== 0) {
return
}
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
x.value = storedPosition.value.x
y.value = storedPosition.value.y
captureLastDragState()
return
}
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
@@ -82,9 +73,25 @@ const setInitialPosition = () => {
return
}
x.value = (screenWidth - menuWidth) / 2
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
captureLastDragState()
// Check if stored position exists and is within bounds
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
// Ensure stored position is within screen bounds
x.value = clamp(storedPosition.value.x, 0, screenWidth - menuWidth)
y.value = clamp(storedPosition.value.y, 0, screenHeight - menuHeight)
captureLastDragState()
return
}
// If no stored position or current position, set to bottom center
if (x.value === 0 && y.value === 0) {
x.value = clamp((screenWidth - menuWidth) / 2, 0, screenWidth - menuWidth)
y.value = clamp(
screenHeight - menuHeight - 10,
0,
screenHeight - menuHeight
)
captureLastDragState()
}
}
}
onMounted(setInitialPosition)

View File

@@ -32,6 +32,8 @@ describe('TreeExplorerTreeNode', () => {
handleRename: () => {}
} as RenderedTreeExplorerNode
const mockHandleEditLabel = vi.fn()
beforeAll(() => {
// Create a Vue app instance for PrimeVuePrimeVue
const app = createApp({})
@@ -48,7 +50,10 @@ describe('TreeExplorerTreeNode', () => {
props: { node: mockNode },
global: {
components: { EditableText, Badge },
plugins: [createTestingPinia(), i18n]
plugins: [createTestingPinia(), i18n],
provide: {
[InjectKeyHandleEditLabelFunction]: mockHandleEditLabel
}
}
})
@@ -72,7 +77,10 @@ describe('TreeExplorerTreeNode', () => {
},
global: {
components: { EditableText, Badge, InputText },
plugins: [createTestingPinia(), i18n, PrimeVue]
plugins: [createTestingPinia(), i18n, PrimeVue],
provide: {
[InjectKeyHandleEditLabelFunction]: mockHandleEditLabel
}
}
})

View File

@@ -70,11 +70,12 @@ const state = computed<GridState>(() => {
const fromCol = fromRow * cols.value
const toCol = toRow * cols.value
const remainingCol = items.length - toCol
const hasMoreToRender = remainingCol >= 0
return {
start: clamp(fromCol, 0, items?.length),
end: clamp(toCol, fromCol, items?.length),
isNearEnd: remainingCol <= cols.value * bufferRows
isNearEnd: hasMoreToRender && remainingCol <= cols.value * bufferRows
}
})
const renderedItems = computed(() =>

View File

@@ -1,74 +0,0 @@
<template>
<div class="flex flex-col gap-12 p-2 w-96">
<img src="@/assets/images/api-nodes-news.webp" alt="API Nodes News" />
<div class="flex flex-col gap-2 justify-center items-center">
<div class="text-xl">
{{ $t('apiNodesNews.introducing') }}
<span class="text-amber-500">API NODES</span>
</div>
<div class="text-muted">{{ $t('apiNodesNews.subtitle') }}</div>
</div>
<div class="flex flex-col gap-4">
<div
v-for="(step, index) in steps"
:key="index"
class="grid grid-cols-[auto_1fr] gap-2 items-center"
>
<Tag class="w-8 h-8" :value="index + 1" rounded />
<div class="flex flex-col gap-2">
<div>{{ step.title }}</div>
<div v-if="step.subtitle" class="text-muted">
{{ step.subtitle }}
</div>
</div>
</div>
</div>
<div class="flex flex-row justify-between">
<Button label="Learn More" text @click="handleLearnMore" />
<Button label="Close" @click="onClose" />
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import { onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const steps: {
title: string
subtitle?: string
}[] = [
{
title: t('apiNodesNews.steps.step1.title'),
subtitle: t('apiNodesNews.steps.step1.subtitle')
},
{
title: t('apiNodesNews.steps.step2.title'),
subtitle: t('apiNodesNews.steps.step2.subtitle')
},
{
title: t('apiNodesNews.steps.step3.title')
},
{
title: t('apiNodesNews.steps.step4.title')
}
]
const { onClose } = defineProps<{
onClose: () => void
}>()
const handleLearnMore = () => {
window.open('https://blog.comfy.org/p/comfyui-native-api-nodes', '_blank')
}
onBeforeUnmount(() => {
localStorage.setItem('api-nodes-news-seen', 'true')
})
</script>

View File

@@ -67,9 +67,9 @@ import Tabs from 'primevue/tabs'
import { computed, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSettingSearch } from '@/composables/setting/useSettingSearch'
import { useSettingUI } from '@/composables/setting/useSettingUI'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { SettingTreeNode } from '@/stores/settingStore'
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
import { flattenTree } from '@/utils/treeUtil'
@@ -107,7 +107,7 @@ const {
getSearchResults
} = useSettingSearch()
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
// Sort groups for a category
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
@@ -140,7 +140,7 @@ watch(activeCategory, (_, oldValue) => {
activeCategory.value = oldValue
}
if (activeCategory.value?.key === 'credits') {
void authService.fetchBalance()
void authActions.fetchBalance()
}
})
</script>

View File

@@ -94,13 +94,22 @@
<small class="text-muted text-center">
{{ t('auth.apiKey.helpText') }}
<a
href="https://platform.comfy.org/login"
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.generateKey') }}
</a>
</small>
<Message
v-if="authActions.accessError.value"
severity="info"
icon="pi pi-info-circle"
variant="outlined"
closable
>
{{ t('toastMessages.useApiKeyTip') }}
</Message>
</div>
<!-- Terms & Contact -->
@@ -134,11 +143,12 @@
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { isInChina } from '@/utils/networkUtil'
import ApiKeyForm from './signin/ApiKeyForm.vue'
@@ -150,7 +160,7 @@ const { onSuccess } = defineProps<{
}>()
const { t } = useI18n()
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
@@ -161,25 +171,25 @@ const toggleState = () => {
}
const signInWithGoogle = async () => {
if (await authService.signInWithGoogle()) {
if (await authActions.signInWithGoogle()) {
onSuccess()
}
}
const signInWithGithub = async () => {
if (await authService.signInWithGithub()) {
if (await authActions.signInWithGithub()) {
onSuccess()
}
}
const signInWithEmail = async (values: SignInData) => {
if (await authService.signInWithEmail(values.email, values.password)) {
if (await authActions.signInWithEmail(values.email, values.password)) {
onSuccess()
}
}
const signUpWithEmail = async (values: SignUpData) => {
if (await authService.signUpWithEmail(values.email, values.password)) {
if (await authActions.signUpWithEmail(values.email, values.password)) {
onSuccess()
}
}
@@ -188,4 +198,8 @@ const userIsInChina = ref(false)
onMounted(async () => {
userIsInChina.value = await isInChina()
})
onUnmounted(() => {
authActions.accessError.value = false
})
</script>

View File

@@ -51,7 +51,7 @@
import Button from 'primevue/button'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
@@ -65,9 +65,9 @@ const {
preselectedAmountOption?: number
}>()
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const handleSeeDetails = async () => {
await authService.accessBillingPortal()
await authActions.accessBillingPortal()
}
</script>

View File

@@ -23,10 +23,10 @@ import Button from 'primevue/button'
import { ref } from 'vue'
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { updatePasswordSchema } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const loading = ref(false)
const { onSuccess } = defineProps<{
@@ -37,7 +37,7 @@ const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
loading.value = true
try {
await authService.updatePassword(event.values.password)
await authActions.updatePassword(event.values.password)
onSuccess()
} finally {
loading.value = false

View File

@@ -41,9 +41,9 @@ import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const {
amount,
@@ -61,7 +61,7 @@ const loading = ref(false)
const handleBuyNow = async () => {
loading.value = true
await authService.purchaseCredits(editable ? customAmount.value : amount)
await authActions.purchaseCredits(editable ? customAmount.value : amount)
loading.value = false
didClickBuyNow.value = true
}
@@ -69,7 +69,7 @@ const handleBuyNow = async () => {
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authService.fetchBalance()
void authActions.fetchBalance()
}
})
</script>

View File

@@ -55,6 +55,7 @@
/>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="3"
:grid-style="GRID_STYLE"
@@ -92,7 +93,7 @@
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import { computed, 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'
@@ -199,6 +200,10 @@ const {
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
whenever(selectedTab, () => {
pageNumber.value = 0
})
const isUpdateAvailableTab = computed(
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
)
@@ -419,6 +424,17 @@ whenever(selectedNodePack, async () => {
}
})
let gridContainer: HTMLElement | null = null
onMounted(() => {
gridContainer = document.getElementById('results-grid')
})
watch(searchQuery, () => {
gridContainer ??= document.getElementById('results-grid')
if (gridContainer) {
gridContainer.scrollTop = 0
}
})
onUnmounted(() => {
getPackById.cancel()
})

View File

@@ -20,6 +20,7 @@
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>

View File

@@ -36,7 +36,7 @@
text
size="small"
severity="secondary"
@click="() => authService.fetchBalance()"
@click="() => authActions.fetchBalance()"
/>
</div>
</div>
@@ -112,8 +112,8 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
@@ -127,7 +127,7 @@ interface CreditHistoryItemData {
const { t } = useI18n()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
@@ -142,7 +142,7 @@ const handlePurchaseCreditsClick = () => {
}
const handleCreditsHistoryClick = async () => {
await authService.accessBillingPortal()
await authActions.accessBillingPortal()
}
const handleMessageSupport = () => {

View File

@@ -1,6 +1,8 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -19,7 +21,16 @@ describe('SettingItem', () => {
const mountComponent = (props: any, options = {}): any => {
return mount(SettingItem, {
global: {
plugins: [PrimeVue, i18n, createPinia()]
plugins: [PrimeVue, i18n, createPinia()],
components: {
Tag
},
directives: {
tooltip: Tooltip
},
stubs: {
'i-material-symbols:experiment-outline': true
}
},
props,
...options

View File

@@ -26,9 +26,9 @@
<h3 class="font-medium">
{{ $t('userSettings.email') }}
</h3>
<a :href="'mailto:' + userEmail" class="hover:underline">
<span class="text-muted">
{{ userEmail }}
</a>
</span>
</div>
<div class="flex flex-col gap-0.5">

View File

@@ -9,6 +9,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
@@ -39,7 +41,8 @@ const i18n = createI18n({
error: 'Invalid API Key',
helpText: 'Need an API key?',
generateKey: 'Get one here',
whitelistInfo: 'About non-whitelisted sites'
whitelistInfo: 'About non-whitelisted sites',
description: 'Use your Comfy API key to enable API Nodes'
}
},
g: {
@@ -108,7 +111,7 @@ describe('ApiKeyForm', () => {
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
'https://platform.comfy.org/login'
`${COMFY_PLATFORM_BASE_URL}/login`
)
})
})

View File

@@ -48,7 +48,7 @@
<small class="text-muted">
{{ t('auth.apiKey.helpText') }}
<a
href="https://platform.comfy.org/login"
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
target="_blank"
class="text-blue-500 cursor-pointer"
>
@@ -87,6 +87,7 @@ import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'

View File

@@ -80,12 +80,12 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { type SignInData, signInSchema } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const firebaseAuthService = useFirebaseAuthService()
const firebaseAuthActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const { t } = useI18n()
@@ -102,6 +102,6 @@ const onSubmit = (event: FormSubmitEvent) => {
const handleForgotPassword = async (email: string) => {
if (!email) return
await firebaseAuthService.sendPasswordReset(email)
await firebaseAuthActions.sendPasswordReset(email)
}
</script>

View File

@@ -12,16 +12,17 @@
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { computed, watch } from 'vue'
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { type DomWidgetState, useDomWidgetStore } from '@/stores/domWidgetStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgetStates = computed(
() => Array.from(domWidgetStore.widgetStates.values()) as DomWidgetState[]
const widgetStates = computed(() =>
Array.from(domWidgetStore.widgetStates.values())
)
const updateWidgets = () => {
@@ -54,18 +55,13 @@ const updateWidgets = () => {
}
const canvasStore = useCanvasStore()
watch(
whenever(
() => canvasStore.canvas,
(lgCanvas) => {
if (!lgCanvas) return
lgCanvas.onDrawForeground = useChainCallback(
lgCanvas.onDrawForeground,
() => {
updateWidgets()
}
)
},
(canvas) =>
(canvas.onDrawForeground = useChainCallback(
canvas.onDrawForeground,
updateWidgets
)),
{ immediate: true }
)
</script>

View File

@@ -27,7 +27,6 @@
class="w-full h-full touch-none"
/>
<NodeBadge />
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover />
@@ -53,7 +52,6 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import NodeBadge from '@/components/graph/NodeBadge.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
@@ -62,6 +60,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
@@ -71,7 +70,7 @@ import { usePaste } from '@/composables/usePaste'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n } from '@/i18n'
import { i18n, t } from '@/i18n'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -225,39 +224,13 @@ watch(
}
)
// Save the drag & scale info in the serialized workflow if the setting is enabled
watch(
[
() => canvasStore.canvas,
() => settingStore.get('Comfy.EnableWorkflowViewRestore')
],
([canvas, enableWorkflowViewRestore]) => {
const extra = canvas?.graph?.extra
if (!extra) return
if (enableWorkflowViewRestore) {
extra.ds = {
get scale() {
return canvas.ds.scale
},
get offset() {
const [x, y] = canvas.ds.offset
return [x, y]
}
}
} else {
delete extra.ds
}
}
)
useEventListener(
canvasRef,
'litegraph:no-items-selected',
() => {
toastStore.add({
severity: 'warn',
summary: 'No items selected',
summary: t('toastMessages.nothingSelected'),
life: 2000
})
},
@@ -280,6 +253,7 @@ const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
onMounted(async () => {
useGlobalLitegraph()
@@ -293,7 +267,7 @@ onMounted(async () => {
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init(comfyApp)
ChangeTracker.init()
await loadCustomNodesI18n()
try {
await settingStore.loadSettingValues()
@@ -318,10 +292,8 @@ onMounted(async () => {
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
// @ts-expect-error fixme ts strict error
window['app'] = comfyApp
// @ts-expect-error fixme ts strict error
window['graph'] = comfyApp.graph
window.app = comfyApp
window.graph = comfyApp.graph
comfyAppReady.value = true

View File

@@ -1,114 +0,0 @@
<template>
<div>
<!-- This component does not render anything visible. -->
</div>
</template>
<script setup lang="ts">
import {
BadgePosition,
LGraphBadge,
type LGraphNode
} from '@comfyorg/litegraph'
import _ from 'lodash'
import { computed, onMounted, watch } from 'vue'
import { app } from '@/scripts/app'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { NodeBadgeMode } from '@/types/nodeSource'
const settingStore = useSettingStore()
const colorPaletteStore = useColorPaletteStore()
const nodeSourceBadgeMode = computed(
() => settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
)
const nodeIdBadgeMode = computed(
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
)
const nodeLifeCycleBadgeMode = computed(
() =>
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') as NodeBadgeMode
)
watch([nodeSourceBadgeMode, nodeIdBadgeMode, nodeLifeCycleBadgeMode], () => {
app.graph?.setDirtyCanvas(true, true)
})
const nodeDefStore = useNodeDefStore()
function badgeTextVisible(
nodeDef: ComfyNodeDefImpl | null,
badgeMode: NodeBadgeMode
): boolean {
return !(
badgeMode === NodeBadgeMode.None ||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
)
}
onMounted(() => {
app.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
node.badgePosition = BadgePosition.TopRight
const badge = computed(() => {
const nodeDef = nodeDefStore.fromLGraphNode(node)
return new LGraphBadge({
text: _.truncate(
[
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
? `#${node.id}`
: '',
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
? nodeDef?.nodeLifeCycleBadgeText ?? ''
: '',
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
? nodeDef?.nodeSource?.badgeText ?? ''
: ''
]
.filter((s) => s.length > 0)
.join(' '),
{
length: 31
}
),
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_BG_COLOR
})
})
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node) {
const creditsBadge = computed(() => {
return new LGraphBadge({
text: '',
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: '#FABC25',
bgColor: '#353535',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_BG_COLOR
})
})
node.badges.push(() => creditsBadge.value)
}
}
})
})
</script>

View File

@@ -14,12 +14,10 @@
<script setup lang="ts">
import { createBounds } from '@comfyorg/litegraph'
import type { LGraphCanvas } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasStore } from '@/stores/graphStore'
const canvasStore = useCanvasStore()
@@ -28,8 +26,8 @@ const { style, updatePosition } = useAbsolutePosition()
const visible = ref(false)
const showBorder = ref(false)
const positionSelectionOverlay = (canvas: LGraphCanvas) => {
const selectedItems = canvas.selectedItems
const positionSelectionOverlay = () => {
const { selectedItems } = canvasStore.getCanvas()
showBorder.value = selectedItems.size > 1
if (!selectedItems.size) {
@@ -48,26 +46,18 @@ const positionSelectionOverlay = (canvas: LGraphCanvas) => {
}
// Register listener on canvas creation.
watch(
() => canvasStore.canvas as LGraphCanvas | null,
(canvas: LGraphCanvas | null) => {
if (!canvas) return
canvas.onSelectionChange = useChainCallback(
canvas.onSelectionChange,
// Wait for next frame as sometimes the selected items haven't been
// rendered yet, so the boundingRect is not available on them.
() => requestAnimationFrame(() => positionSelectionOverlay(canvas))
)
whenever(
() => canvasStore.getCanvas().state.selectionChanged,
() => {
requestAnimationFrame(() => {
positionSelectionOverlay()
canvasStore.getCanvas().state.selectionChanged = false
})
},
{ immediate: true }
)
whenever(
() => canvasStore.getCanvas().ds.state,
() => positionSelectionOverlay(canvasStore.getCanvas()),
{ deep: true }
)
canvasStore.getCanvas().ds.onChanged = positionSelectionOverlay
watch(
() => canvasStore.canvas?.state?.draggingItems,
@@ -77,10 +67,10 @@ watch(
// the correct position.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/2656
if (draggingItems === false) {
setTimeout(() => {
requestAnimationFrame(() => {
visible.value = true
positionSelectionOverlay(canvasStore.canvas as LGraphCanvas)
}, 100)
positionSelectionOverlay()
})
} else {
// Selection change update to visible state is delayed by a frame. Here
// we also delay a frame so that the order of events is correct when

View File

@@ -6,96 +6,41 @@
content: 'p-0 flex flex-row'
}"
>
<ExecuteButton v-show="nodeSelected" />
<ColorPickerButton v-show="nodeSelected || groupSelected" />
<Button
v-show="nodeSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
showDelay: 1000
}"
severity="secondary"
text
data-testid="bypass-button"
@click="
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
"
>
<template #icon>
<i-game-icons:detour />
</template>
</Button>
<Button
v-show="nodeSelected || groupSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-thumbtack"
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
/>
<Button
v-tooltip.top="{
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
}"
severity="danger"
text
icon="pi pi-trash"
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
/>
<Button
v-show="isRefreshable"
severity="info"
text
icon="pi pi-refresh"
@click="refreshSelected"
/>
<Button
<ExecuteButton />
<ColorPickerButton />
<BypassButton />
<PinButton />
<DeleteButton />
<RefreshButton />
<ExtensionCommandButton
v-for="command in extensionToolboxCommands"
:key="command.id"
v-tooltip.top="{
value:
st(`commands.${normalizeI18nKey(command.id)}.label`, '') || undefined,
showDelay: 1000
}"
severity="secondary"
text
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
@click="() => commandStore.execute(command.id)"
:command="command"
/>
</Panel>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Panel from 'primevue/panel'
import { computed } from 'vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
import { st, t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import BypassButton from './selectionToolbox/BypassButton.vue'
import DeleteButton from './selectionToolbox/DeleteButton.vue'
import ExtensionCommandButton from './selectionToolbox/ExtensionCommandButton.vue'
import PinButton from './selectionToolbox/PinButton.vue'
import RefreshButton from './selectionToolbox/RefreshButton.vue'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const { isRefreshable, refreshSelected } = useRefreshableSelection()
const nodeSelected = computed(() =>
canvasStore.selectedItems.some(isLGraphNode)
)
const groupSelected = computed(() =>
canvasStore.selectedItems.some(isLGraphGroup)
)
const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(
canvasStore.selectedItems
.map(
@@ -108,7 +53,7 @@ const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
)
return Array.from(commandIds)
.map((commandId) => commandStore.getCommand(commandId))
.filter((command) => command !== undefined)
.filter((command): command is ComfyCommandImpl => command !== undefined)
})
</script>

View File

@@ -0,0 +1,31 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
showDelay: 1000
}"
severity="secondary"
text
data-testid="bypass-button"
@click="
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
"
>
<template #icon>
<i-game-icons:detour />
</template>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
</script>

View File

@@ -1,6 +1,7 @@
<template>
<div class="relative">
<Button
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
severity="secondary"
text
@click="() => (showColorPicker = !showColorPicker)"

View File

@@ -0,0 +1,22 @@
<template>
<Button
v-tooltip.top="{
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
}"
severity="danger"
text
icon="pi pi-trash"
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const commandStore = useCommandStore()
</script>

View File

@@ -1,5 +1,6 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-tooltip.top="{
value: isDisabled
? t('selectionToolbox.executeButton.disabledTooltip')
@@ -36,7 +37,7 @@ const buttonHovered = ref(false)
const selectedOutputNodes = computed(
() =>
canvasStore.selectedItems.filter(
(item) => isLGraphNode(item) && item.constructor.nodeData.output_node
(item) => isLGraphNode(item) && item.constructor.nodeData?.output_node
) as LGraphNode[]
)
@@ -45,7 +46,7 @@ const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
function outputNodeStokeStyle(this: LGraphNode) {
if (
this.selected &&
this.constructor.nodeData.output_node &&
this.constructor.nodeData?.output_node &&
buttonHovered.value
) {
return { color: 'orange', lineWidth: 2, padding: 10 }

View File

@@ -0,0 +1,27 @@
<template>
<Button
v-tooltip.top="{
value:
st(`commands.${normalizeI18nKey(command.id)}.label`, '') || undefined,
showDelay: 1000
}"
severity="secondary"
text
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
@click="() => commandStore.execute(command.id)"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { st } from '@/i18n'
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
defineProps<{
command: ComfyCommand
}>()
const commandStore = useCommandStore()
</script>

View File

@@ -0,0 +1,25 @@
<template>
<Button
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-thumbtack"
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
</script>

View File

@@ -0,0 +1,17 @@
<template>
<Button
v-show="isRefreshable"
severity="info"
text
icon="pi pi-refresh"
@click="refreshSelected"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
const { isRefreshable, refreshSelected } = useRefreshableSelection()
</script>

View File

@@ -0,0 +1,135 @@
<template>
<ScrollPanel
ref="scrollPanelRef"
class="w-full min-h-[400px] rounded-lg px-2 py-2 text-xs"
:pt="{ content: { id: 'chat-scroll-content' } }"
>
<div v-for="(item, i) in parsedHistory" :key="i" class="mb-4">
<!-- Prompt (user, right) -->
<span
:class="{
'opacity-40 pointer-events-none': editIndex !== null && i > editIndex
}"
>
<div class="flex justify-end mb-1">
<div
class="bg-gray-300 dark-theme:bg-gray-800 rounded-xl px-4 py-1 max-w-[80%] text-right"
>
<div class="break-words text-[12px]">{{ item.prompt }}</div>
</div>
</div>
<div class="flex justify-end mb-2 mr-1">
<CopyButton :text="item.prompt" />
<Button
v-tooltip="
editIndex === i ? $t('chatHistory.cancelEditTooltip') : null
"
text
rounded
class="!p-1 !h-4 !w-4 text-gray-400 hover:text-gray-600 dark-theme:hover:text-gray-200 transition"
pt:icon:class="!text-xs"
:icon="editIndex === i ? 'pi pi-times' : 'pi pi-pencil'"
:aria-label="
editIndex === i ? $t('chatHistory.cancelEdit') : $t('g.edit')
"
@click="editIndex === i ? handleCancelEdit() : handleEdit(i)"
/>
</div>
</span>
<!-- Response (LLM, left) -->
<ResponseBlurb
:text="item.response"
:class="{
'opacity-25 pointer-events-none': editIndex !== null && i >= editIndex
}"
>
<div v-html="nl2br(linkifyHtml(item.response))" />
</ResponseBlurb>
</div>
</ScrollPanel>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ScrollPanel from 'primevue/scrollpanel'
import { computed, nextTick, ref, watch } from 'vue'
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
import ResponseBlurb from '@/components/graph/widgets/chatHistory/ResponseBlurb.vue'
import { ComponentWidget } from '@/scripts/domWidget'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const { widget, history = '[]' } = defineProps<{
widget?: ComponentWidget<string>
history: string
}>()
const editIndex = ref<number | null>(null)
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
const parsedHistory = computed(() => JSON.parse(history || '[]'))
const findPromptInput = () =>
widget?.node.widgets?.find((w) => w.name === 'prompt')
let promptInput = findPromptInput()
const previousPromptInput = ref<string | null>(null)
const getPreviousResponseId = (index: number) =>
index > 0 ? parsedHistory.value[index - 1]?.response_id ?? '' : ''
const storePromptInput = () => {
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
if (!promptInput) return
previousPromptInput.value = String(promptInput.value)
}
const setPromptInput = (text: string, previousResponseId?: string | null) => {
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
if (!promptInput) return
if (previousResponseId !== null) {
promptInput.value = `<starting_point_id:${previousResponseId}>\n\n${text}`
} else {
promptInput.value = text
}
}
const handleEdit = (index: number) => {
if (!promptInput) return
editIndex.value = index
const prevResponseId = index === 0 ? 'start' : getPreviousResponseId(index)
const promptText = parsedHistory.value[index]?.prompt ?? ''
storePromptInput()
setPromptInput(promptText, prevResponseId)
}
const resetEditingState = () => {
editIndex.value = null
}
const handleCancelEdit = () => {
resetEditingState()
if (promptInput) {
promptInput.value = previousPromptInput.value ?? ''
}
}
const scrollChatToBottom = () => {
const content = document.getElementById('chat-scroll-content')
if (content) {
content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' })
}
}
const onHistoryChanged = () => {
resetEditingState()
void nextTick(() => scrollChatToBottom())
}
watch(() => parsedHistory.value, onHistoryChanged, {
immediate: true,
deep: true
})
</script>

View File

@@ -11,6 +11,7 @@
v-if="isComponentWidget(widget)"
:model-value="widget.value"
:widget="widget"
v-bind="widget.props"
@update:model-value="emit('update:widgetValue', $event)"
/>
</div>

View File

@@ -0,0 +1,53 @@
<template>
<div
class="relative w-full text-xs min-h-[28px] max-h-[200px] rounded-lg px-4 py-2 overflow-y-auto"
>
<div class="flex items-center gap-2">
<div class="flex-1 break-all flex items-center gap-2">
<span v-html="formattedText"></span>
<Skeleton v-if="isParentNodeExecuting" class="!flex-1 !h-4" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { NodeId } from '@comfyorg/litegraph'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref, watch } from 'vue'
import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
defineProps<{
widget?: object
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
let executingNodeId: NodeId | null = null
onMounted(() => {
executingNodeId = executionStore.executingNodeId
})
// Watch for either a new node has starting execution or overall execution ending
const stopWatching = watch(
[() => executionStore.executingNode, () => executionStore.isIdle],
() => {
if (
executionStore.isIdle ||
(executionStore.executingNode &&
executionStore.executingNode.id !== executingNodeId)
) {
isParentNodeExecuting.value = false
stopWatching()
}
if (!executingNodeId) {
executingNodeId = executionStore.executingNodeId
}
}
)
</script>

View File

@@ -0,0 +1,36 @@
<template>
<Button
v-tooltip="
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
"
text
rounded
class="!p-1 !h-4 !w-6 text-gray-400 hover:text-gray-600 dark-theme:hover:text-gray-200 transition"
pt:icon:class="!text-xs"
:icon="copied ? 'pi pi-check' : 'pi pi-copy'"
:aria-label="
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
"
@click="handleCopy"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
const { text } = defineProps<{
text: string
}>()
const copied = ref(false)
const handleCopy = async () => {
if (!text) return
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 1024)
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<span>
<div class="flex justify-start mb-1">
<div class="rounded-xl px-4 py-1 max-w-[80%]">
<div class="break-words text-[12px]">
<slot />
</div>
</div>
</div>
<div class="flex justify-start ml-1">
<CopyButton :text="text" />
</div>
</span>
</template>
<script setup lang="ts">
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
defineProps<{
text: string
}>()
</script>

View File

@@ -52,6 +52,9 @@ const eventConfig = {
showPreviewChange: (value: boolean) => emit('showPreviewChange', value),
backgroundImageChange: (value: string) =>
emit('backgroundImageChange', value),
backgroundImageLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.loadingBackgroundImage')),
backgroundImageLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
upDirectionChange: (value: string) => emit('upDirectionChange', value),
edgeThresholdChange: (value: number) => emit('edgeThresholdChange', value),
modelLoadingStart: () =>
@@ -75,7 +78,7 @@ const eventConfig = {
watchEffect(async () => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value)
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setBackgroundColor(props.backgroundColor)
rawLoad3d.toggleGrid(props.showGrid)
@@ -84,15 +87,25 @@ watchEffect(async () => {
rawLoad3d.toggleCamera(props.cameraType)
rawLoad3d.togglePreview(props.showPreview)
await rawLoad3d.setBackgroundImage(props.backgroundImage)
rawLoad3d.setUpDirection(props.upDirection)
}
})
watch(
() => props.upDirection,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setUpDirection(newValue)
}
}
)
watch(
() => props.materialMode,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value)
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setMaterialMode(newValue)
}
@@ -102,10 +115,9 @@ watch(
watch(
() => props.edgeThreshold,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value)
if (load3d.value && newValue) {
const rawLoad3d = toRaw(load3d.value) as Load3d
// @ts-expect-error fixme ts strict error
rawLoad3d.setEdgeThreshold(newValue)
}
}

View File

@@ -8,6 +8,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useExtensionStore } from '@/stores/extensionStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -16,9 +17,16 @@ const props = defineProps<{
}>()
const settingStore = useSettingStore()
const vhsAdvancedPreviews = computed(() =>
settingStore.get('VHS.AdvancedPreviews')
)
const { isExtensionInstalled, isExtensionEnabled } = useExtensionStore()
const vhsAdvancedPreviews = computed(() => {
return (
isExtensionInstalled('VideoHelperSuite.Core') &&
isExtensionEnabled('VideoHelperSuite.Core') &&
settingStore.get('VHS.AdvancedPreviews') &&
settingStore.get('VHS.AdvancedPreviews') !== 'Never'
)
})
const url = computed(() =>
vhsAdvancedPreviews.value

View File

@@ -69,11 +69,11 @@ import { onMounted } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
const authService = useFirebaseAuthService()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const handleOpenUserSettings = () => {
@@ -89,6 +89,6 @@ const handleOpenApiPricing = () => {
}
onMounted(() => {
void authService.fetchBalance()
void authActions.fetchBalance()
})
</script>

310
src/composables/README.md Normal file
View File

@@ -0,0 +1,310 @@
# Composables
This directory contains Vue composables for the ComfyUI frontend application. Composables are reusable pieces of logic that encapsulate stateful functionality and can be shared across components.
## Table of Contents
- [Overview](#overview)
- [Composable Architecture](#composable-architecture)
- [Composable Categories](#composable-categories)
- [Usage Guidelines](#usage-guidelines)
- [VueUse Library](#vueuse-library)
- [Development Guidelines](#development-guidelines)
- [Common Patterns](#common-patterns)
## Overview
Vue composables are a core part of Vue 3's Composition API and provide a way to extract and reuse stateful logic between multiple components. In ComfyUI, composables are used to encapsulate behaviors like:
- State management
- DOM interactions
- Feature-specific functionality
- UI behaviors
- Data fetching
Composables enable a more modular and functional approach to building components, allowing for better code reuse and separation of concerns. They help keep your component code cleaner by extracting complex logic into separate, reusable functions.
As described in the [Vue.js documentation](https://vuejs.org/guide/reusability/composables.html), composables are:
> Functions that leverage Vue's Composition API to encapsulate and reuse stateful logic.
## Composable Architecture
The composable architecture in ComfyUI follows these principles:
1. **Single Responsibility**: Each composable should focus on a specific concern
2. **Composition**: Composables can use other composables
3. **Reactivity**: Composables leverage Vue's reactivity system
4. **Reusability**: Composables are designed to be used across multiple components
The following diagram shows how composables fit into the application architecture:
```
┌─────────────────────────────────────────────────────────┐
│ Vue Components │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Component A │ │ Component B │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
└────────────┼───────────────────┼────────────────────────┘
│ │
▼ ▼
┌────────────┴───────────────────┴────────────────────────┐
│ Composables │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ useFeatureA │ │ useFeatureB │ │ useFeatureC │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
└─────────┼────────────────┼────────────────┼─────────────┘
│ │ │
▼ ▼ ▼
┌─────────┴────────────────┴────────────────┴─────────────┐
│ Services & Stores │
└─────────────────────────────────────────────────────────┘
```
## Composable Categories
ComfyUI's composables are organized into several categories:
### Auth
Composables for authentication and user management:
- `useCurrentUser` - Provides access to the current user information
- `useFirebaseAuthActions` - Handles Firebase authentication operations
### Element
Composables for DOM and element interactions:
- `useAbsolutePosition` - Handles element positioning
- `useDomClipping` - Manages clipping of DOM elements
- `useResponsiveCollapse` - Manages responsive collapsing of elements
### Node
Composables for node-specific functionality:
- `useNodeBadge` - Handles node badge display and interaction
- `useNodeImage` - Manages node image preview
- `useNodeDragAndDrop` - Handles drag and drop for nodes
- `useNodeChatHistory` - Manages chat history for nodes
### Settings
Composables for settings management:
- `useSettingSearch` - Provides search functionality for settings
- `useSettingUI` - Manages settings UI interactions
### Sidebar
Composables for sidebar functionality:
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
- `useQueueSidebarTab` - Manages the queue sidebar tab
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
### Widgets
Composables for widget functionality:
- `useBooleanWidget` - Manages boolean widget interactions
- `useComboWidget` - Manages combo box widget interactions
- `useFloatWidget` - Manages float input widget interactions
- `useImagePreviewWidget` - Manages image preview widget
## Usage Guidelines
When using composables in components, follow these guidelines:
1. **Import and call** composables at the top level of the `setup` function
2. **Destructure returned values** to use in your component
3. **Respect reactivity** by not destructuring reactive objects
4. **Handle cleanup** by using `onUnmounted` when necessary
5. **Use VueUse** for common functionality instead of writing from scratch
Example usage:
```vue
<template>
<div
:class="{ 'dragging': isDragging }"
@mousedown="startDrag"
@mouseup="endDrag"
>
<img v-if="imageUrl" :src="imageUrl" alt="Node preview" />
</div>
</template>
<script setup lang="ts">
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop';
import { useNodeImage } from '@/composables/node/useNodeImage';
// Use composables at the top level
const { isDragging, startDrag, endDrag } = useNodeDragAndDrop();
const { imageUrl, loadImage } = useNodeImage();
// Use returned values in your component
</script>
```
## VueUse Library
ComfyUI leverages the [VueUse](https://vueuse.org/) library, which provides a collection of essential Vue Composition API utilities. Instead of implementing common functionality from scratch, prefer using VueUse composables for:
- DOM event handling (`useEventListener`, `useMouseInElement`)
- Element measurements (`useElementBounding`, `useElementSize`)
- Asynchronous operations (`useAsyncState`, `useFetch`)
- Animation and timing (`useTransition`, `useTimeout`, `useInterval`)
- Browser APIs (`useLocalStorage`, `useClipboard`)
- Sensors (`useDeviceMotion`, `useDeviceOrientation`)
- State management (`createGlobalState`, `useStorage`)
- ...and [more](https://vueuse.org/functions.html)
Examples:
```js
// Instead of manually adding/removing event listeners
import { useEventListener } from '@vueuse/core'
useEventListener(window, 'resize', handleResize)
// Instead of manually tracking element measurements
import { useElementBounding } from '@vueuse/core'
const { width, height, top, left } = useElementBounding(elementRef)
// Instead of manual async state management
import { useAsyncState } from '@vueuse/core'
const { state, isReady, isLoading } = useAsyncState(
fetch('https://api.example.com/data').then(r => r.json()),
{ data: [] }
)
```
For a complete list of available functions, see the [VueUse documentation](https://vueuse.org/functions.html).
## Development Guidelines
When creating or modifying composables, follow these best practices:
1. **Name with `use` prefix**: All composables should start with "use"
2. **Return an object**: Composables should return an object with named properties/methods
3. **Handle cleanup**: Use `onUnmounted` to clean up resources
4. **Document parameters and return values**: Add JSDoc comments
5. **Test composables**: Write unit tests for composable functionality
6. **Use VueUse**: Leverage VueUse composables instead of reimplementing common functionality
7. **Implement proper cleanup**: Cancel debounced functions, pending requests, and clear maps
8. **Use watchDebounced/watchThrottled**: For performance-sensitive reactive operations
### Composable Template
Here's a template for creating a new composable:
```typescript
import { ref, computed, onMounted, onUnmounted } from 'vue';
/**
* Composable for [functionality description]
* @param options Configuration options
* @returns Object containing state and methods
*/
export function useExample(options = {}) {
// State
const state = ref({
// Initial state
});
// Computed values
const derivedValue = computed(() => {
// Compute from state
return state.value.someProperty;
});
// Methods
function doSomething() {
// Implementation
}
// Lifecycle hooks
onMounted(() => {
// Setup
});
onUnmounted(() => {
// Cleanup
});
// Return exposed state and methods
return {
state,
derivedValue,
doSomething
};
}
```
## Common Patterns
Composables in ComfyUI frequently use these patterns:
### State Management
```typescript
export function useState() {
const count = ref(0);
function increment() {
count.value++;
}
return {
count,
increment
};
}
```
### Event Handling with VueUse
```typescript
import { useEventListener } from '@vueuse/core';
export function useKeyPress(key) {
const isPressed = ref(false);
useEventListener('keydown', (e) => {
if (e.key === key) {
isPressed.value = true;
}
});
useEventListener('keyup', (e) => {
if (e.key === key) {
isPressed.value = false;
}
});
return { isPressed };
}
```
### Fetch & Load with VueUse
```typescript
import { useAsyncState } from '@vueuse/core';
export function useFetchData(url) {
const { state: data, isLoading, error, execute: refresh } = useAsyncState(
async () => {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch data');
return response.json();
},
null,
{ immediate: true }
);
return { data, isLoading, error, refresh };
}
```
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).

View File

@@ -1,4 +1,5 @@
import { FirebaseError } from 'firebase/app'
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
@@ -11,11 +12,13 @@ import { usdToMicros } from '@/utils/formatUtil'
* All actions are wrapped with error handling.
* @returns {Object} - Object containing all Firebase Auth actions
*/
export const useFirebaseAuthService = () => {
export const useFirebaseAuthActions = () => {
const authStore = useFirebaseAuthStore()
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
const accessError = ref(false)
const reportError = (error: unknown) => {
// Ref: https://firebase.google.com/docs/auth/admin/errors
if (
@@ -26,6 +29,7 @@ export const useFirebaseAuthService = () => {
'auth/unauthorized-continue-uri'
].includes(error.code)
) {
accessError.value = true
toastStore.add({
severity: 'error',
summary: t('g.error'),
@@ -141,6 +145,7 @@ export const useFirebaseAuthService = () => {
signInWithGithub,
signInWithEmail,
signUpWithEmail,
updatePassword
updatePassword,
accessError
}
}

View File

@@ -0,0 +1,396 @@
import { ApiNodeCostRecord } from '@/types/apiNodeTypes'
/**
* API Node cost data sourced from pricing database
*
* GENERATED FILE - DO NOT EDIT DIRECTLY
* Generated from Price Run Range for each API Node CSV
*/
export const apiNodeCosts: ApiNodeCostRecord = {
FluxProCannyNode: {
vendor: 'Unknown',
nodeName: 'Flux 1: Canny Control Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
displayPrice: '$0.05/Run'
},
FluxProDepthNode: {
vendor: 'Unknown',
nodeName: 'Flux 1: Depth Control Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
displayPrice: '$0.05/Run'
},
FluxProExpandNode: {
vendor: 'Unknown',
nodeName: 'Flux 1: Expand Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
rateDocumentation: 'https://docs.bfl.ml/pricing/',
displayPrice: '$0.05/Run'
},
FluxProFillNode: {
vendor: 'Unknown',
nodeName: 'Flux 1: Fill Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
displayPrice: '$0.05/Run'
},
FluxProUltraImageNode: {
vendor: 'Unknown',
nodeName: 'Flux 1.1: [pro] Ultra Image',
pricingParams: '-',
pricePerRunRange: '$0.06',
displayPrice: '$0.06/Run'
},
IdeogramV1: {
vendor: 'Unknown',
nodeName: 'Ideogram V1',
pricingParams: '-',
pricePerRunRange: '$0.06',
rateDocumentation: 'https://about.ideogram.ai/api-pricing',
displayPrice: '$0.06/Run'
},
IdeogramV2: {
vendor: 'Unknown',
nodeName: 'Ideogram V2',
pricingParams: '-',
pricePerRunRange: '$0.08',
displayPrice: '$0.08/Run'
},
IdeogramV3: {
vendor: 'Unknown',
nodeName: 'Ideogram V3',
pricingParams: 'rendering_speed',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingCameraControlI2VNode: {
vendor: 'Unknown',
nodeName: 'Kling Image to Video (Camera Control)',
pricingParams: '-',
pricePerRunRange: '$0.49',
displayPrice: '$0.49/Run'
},
KlingCameraControlT2VNode: {
vendor: 'Unknown',
nodeName: 'Kling Text to Video (Camera Control)',
pricingParams: '-',
pricePerRunRange: '$0.14',
displayPrice: '$0.14/Run'
},
KlingDualCharacterVideoEffectNode: {
vendor: 'Unknown',
nodeName: 'Kling Dual Character Video Effects',
pricingParams: 'Priced the same as t2v based on mode, model, and duration.',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingImage2VideoNode: {
vendor: 'Unknown',
nodeName: 'Kling Image to Video',
pricingParams: 'Same as Text to Video',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingImageGenerationNode: {
vendor: 'Unknown',
nodeName: 'Kling Image Generation',
pricingParams: 'modality | model',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingLipSyncAudioToVideoNode: {
vendor: 'Unknown',
nodeName: 'Kling Lip Sync Video with Audio',
pricingParams: 'duration of input video',
pricePerRunRange: '$0.07',
displayPrice: '$0.07/Run'
},
KlingLipSyncTextToVideoNode: {
vendor: 'Unknown',
nodeName: 'Kling Lip Sync Video with Text',
pricingParams: 'duration of input video',
pricePerRunRange: '$0.07',
displayPrice: '$0.07/Run'
},
KlingSingleImageVideoEffectNode: {
vendor: 'Unknown',
nodeName: 'Kling Video Effects',
pricingParams: 'effect_scene',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingStartEndFrameNode: {
vendor: 'Unknown',
nodeName: 'Kling Start-End Frame to Video',
pricingParams: 'Same as text to video',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingTextToVideoNode: {
vendor: 'Unknown',
nodeName: 'Kling Text to Video',
pricingParams: 'model | duration | mode',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingVideoExtendNode: {
vendor: 'Unknown',
nodeName: 'Kling Video Extend',
pricingParams: '-',
pricePerRunRange: '$0.28',
displayPrice: '$0.28/Run'
},
KlingVirtualTryOnNode: {
vendor: 'Unknown',
nodeName: 'Kling Virtual Try On',
pricingParams: '-',
pricePerRunRange: '$0.07',
displayPrice: '$0.07/Run'
},
LumaImageToVideoNode: {
vendor: 'Unknown',
nodeName: 'Luma Image to Video',
pricingParams: 'Same as Text to Video',
pricePerRunRange: 'dynamic',
rateDocumentation: 'https://lumalabs.ai/api/pricing',
displayPrice: 'Variable pricing'
},
LumaVideoNode: {
vendor: 'Unknown',
nodeName: 'Luma Text to Video',
pricingParams: 'model | resolution | duration',
pricePerRunRange: 'dynamic',
rateDocumentation: 'https://lumalabs.ai/api/pricing',
displayPrice: 'Variable pricing'
},
MinimaxImageToVideoNode: {
vendor: 'Unknown',
nodeName: 'MiniMax Image to Video',
pricingParams: '-',
pricePerRunRange: '$0.43',
rateDocumentation: 'https://www.minimax.io/price',
displayPrice: '$0.43/Run'
},
MinimaxTextToVideoNode: {
vendor: 'Unknown',
nodeName: 'MiniMax Text to Video',
pricingParams: '-',
pricePerRunRange: '$0.43',
rateDocumentation: 'https://www.minimax.io/price',
displayPrice: '$0.43/Run'
},
OpenAIDalle2: {
vendor: 'Unknown',
nodeName: 'dall-e-2',
pricingParams: 'size',
pricePerRunRange: 'dynamic',
rateDocumentation: 'https://platform.openai.com/docs/pricing',
displayPrice: 'Variable pricing'
},
OpenAIDalle3: {
vendor: 'Unknown',
nodeName: 'dall-e-3',
pricingParams: '1024×1024 | hd',
pricePerRunRange: '$0.08',
displayPrice: '$0.08/Run'
},
OpenAIGPTImage1: {
vendor: 'Unknown',
nodeName: 'gpt-image-1',
pricingParams: 'medium',
pricePerRunRange: '$[0.046 - 0.07]',
displayPrice: '$0.07/Run'
},
PikaImageToVideoNode2_2: {
vendor: 'Unknown',
nodeName: 'Pika Image to Video',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
PikaScenesV2_2: {
vendor: 'Unknown',
nodeName: 'Pika Scenes (Video Image Composition)',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
PikaStartEndFrameNode2_2: {
vendor: 'Unknown',
nodeName: 'Pika Start and End Frame to Video',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
PikaTextToVideoNode2_2: {
vendor: 'Unknown',
nodeName: 'Pika Text to Video',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
Pikadditions: {
vendor: 'Unknown',
nodeName: 'Pikadditions (Video Object Insertion)',
pricingParams: '-',
pricePerRunRange: '$0.3',
displayPrice: '$0.3/Run'
},
Pikaffects: {
vendor: 'Unknown',
nodeName: 'Pikaffects (Video Effects)',
pricingParams: '-',
pricePerRunRange: '0.45',
displayPrice: '$0.45/Run'
},
Pikaswaps: {
vendor: 'Unknown',
nodeName: 'Pika Swaps (Video Object Replacement)',
pricingParams: '-',
pricePerRunRange: '$0.3',
displayPrice: '$0.3/Run'
},
PixverseImageToVideoNode: {
vendor: 'Unknown',
nodeName: 'PixVerse Image to Video',
pricingParams: 'same as text to video',
pricePerRunRange: '$0.9',
displayPrice: '$0.9/Run'
},
PixverseTextToVideoNode: {
vendor: 'Unknown',
nodeName: 'PixVerse Text to Video',
pricingParams: 'duration | quality | motion_mode',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
PixverseTransitionVideoNode: {
vendor: 'Unknown',
nodeName: 'PixVerse Transition Video',
pricingParams: 'same as text to video',
pricePerRunRange: '$0.9',
displayPrice: '$0.9/Run'
},
RecraftCrispUpscaleNode: {
vendor: 'Unknown',
nodeName: 'Recraft Crisp Upscale Image',
pricingParams: '-',
pricePerRunRange: '$0.004',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.004/Run'
},
RecraftImageInpaintingNode: {
vendor: 'Unknown',
nodeName: 'Recraft Image Inpainting',
pricingParams: 'n',
pricePerRunRange: '$$0.04 x n',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$$0.04 x n/Run'
},
RecraftImageToImageNode: {
vendor: 'Unknown',
nodeName: 'Recraft Image to Image',
pricingParams: 'n',
pricePerRunRange: '$0.04 x n',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04 x n/Run'
},
RecraftRemoveBackgroundNode: {
vendor: 'Unknown',
nodeName: 'Recraft Remove Background',
pricingParams: '-',
pricePerRunRange: '$0.01',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.01/Run'
},
RecraftReplaceBackgroundNode: {
vendor: 'Unknown',
nodeName: 'Recraft Replace Background',
pricingParams: 'n',
pricePerRunRange: '$0.04',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04/Run'
},
RecraftTextToImageNode: {
vendor: 'Unknown',
nodeName: 'Recraft Text to Image',
pricingParams: 'model | n',
pricePerRunRange: '$0.04 x n',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04 x n/Run'
},
RecraftTextToVectorNode: {
vendor: 'Unknown',
nodeName: 'Recraft Text to Vector',
pricingParams: 'model | n',
pricePerRunRange: '$0.08 x n',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.08 x n/Run'
},
RecraftVectorizeImageNode: {
vendor: 'Unknown',
nodeName: 'Recraft Vectorize Image',
pricingParams: '-',
pricePerRunRange: '$0.01',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.01/Run'
},
StabilityStableImageSD_3_5Node: {
vendor: 'Unknown',
nodeName: 'Stability AI Stable Diffusion 3.5 Image',
pricingParams: 'model',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
StabilityStableImageUltraNode: {
vendor: 'Unknown',
nodeName: 'Stability AI Stable Image Ultra',
pricingParams: '-',
pricePerRunRange: '$0.08',
displayPrice: '$0.08/Run'
},
StabilityUpscaleConservativeNode: {
vendor: 'Unknown',
nodeName: 'Stability AI Upscale Conservative',
pricingParams: '-',
pricePerRunRange: '$0.25',
displayPrice: '$0.25/Run'
},
StabilityUpscaleCreativeNode: {
vendor: 'Unknown',
nodeName: 'Stability AI Upscale Creative',
pricingParams: '-',
pricePerRunRange: '$0.25',
displayPrice: '$0.25/Run'
},
StabilityUpscaleFastNode: {
vendor: 'Unknown',
nodeName: 'Stability AI Upscale Fast',
pricingParams: '-',
pricePerRunRange: '$0.01',
displayPrice: '$0.01/Run'
},
VeoVideoGenerationNode: {
vendor: 'Unknown',
nodeName: 'Google Veo2 Video Generation',
pricingParams: '10s',
pricePerRunRange: '$5.0',
rateDocumentation:
'https://cloud.google.com/vertex-ai/generative-ai/pricing',
displayPrice: '$5.0/Run'
}
}
/**
* Get the display price for a node
* Returns a default value if the node isn't found
*/
export function getNodeDisplayPrice(
nodeName: string,
defaultPrice = '0.02/Run (approx)'
): string {
return apiNodeCosts[nodeName]?.displayPrice || defaultPrice
}

View File

@@ -0,0 +1,127 @@
import {
BadgePosition,
LGraphBadge,
type LGraphNode
} from '@comfyorg/litegraph'
import _ from 'lodash'
import { computed, onMounted, watch } from 'vue'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import { useNodePricing } from './useNodePricing'
/**
* Add LGraphBadge to LGraphNode based on settings.
*
* Following badges are added:
* - Node ID badge
* - Node source badge
* - Node life cycle badge
* - API node credits badge
*/
export const useNodeBadge = () => {
const settingStore = useSettingStore()
const extensionStore = useExtensionStore()
const colorPaletteStore = useColorPaletteStore()
const nodeSourceBadgeMode = computed(
() =>
settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
)
const nodeIdBadgeMode = computed(
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
)
const nodeLifeCycleBadgeMode = computed(
() =>
settingStore.get(
'Comfy.NodeBadge.NodeLifeCycleBadgeMode'
) as NodeBadgeMode
)
watch([nodeSourceBadgeMode, nodeIdBadgeMode, nodeLifeCycleBadgeMode], () => {
app.graph?.setDirtyCanvas(true, true)
})
const nodeDefStore = useNodeDefStore()
function badgeTextVisible(
nodeDef: ComfyNodeDefImpl | null,
badgeMode: NodeBadgeMode
): boolean {
return !(
badgeMode === NodeBadgeMode.None ||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
)
}
onMounted(() => {
const nodePricing = useNodePricing()
extensionStore.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
node.badgePosition = BadgePosition.TopRight
const badge = computed(() => {
const nodeDef = nodeDefStore.fromLGraphNode(node)
return new LGraphBadge({
text: _.truncate(
[
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
? `#${node.id}`
: '',
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
? nodeDef?.nodeLifeCycleBadgeText ?? ''
: '',
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
? nodeDef?.nodeSource?.badgeText ?? ''
: ''
]
.filter((s) => s.length > 0)
.join(' '),
{
length: 31
}
),
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_BG_COLOR
})
})
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node) {
// Get price from our mapping service
const price = nodePricing.getNodePriceDisplay(node)
const creditsBadge = computed(() => {
return new LGraphBadge({
text: price ?? '',
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: '#FABC25',
bgColor: '#353535',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: '#8D6932'
})
})
node.badges.push(() => creditsBadge.value)
}
}
})
})
}

View File

@@ -0,0 +1,60 @@
import { LGraphNode } from '@comfyorg/litegraph'
import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import { useChatHistoryWidget } from '@/composables/widgets/useChatHistoryWidget'
const CHAT_HISTORY_WIDGET_NAME = '$$node-chat-history'
/**
* Composable for handling node text previews
*/
export function useNodeChatHistory(
options: {
minHeight?: number
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
} = {}
) {
const chatHistoryWidget = useChatHistoryWidget(options)
const findChatHistoryWidget = (node: LGraphNode) =>
node.widgets?.find((w) => w.name === CHAT_HISTORY_WIDGET_NAME)
const addChatHistoryWidget = (node: LGraphNode) =>
chatHistoryWidget(node, {
name: CHAT_HISTORY_WIDGET_NAME,
type: 'chatHistory'
})
/**
* Shows chat history for a node
* @param node The graph node to show the chat history for
*/
function showChatHistory(node: LGraphNode) {
if (!findChatHistoryWidget(node)) {
addChatHistoryWidget(node)
}
node.setDirtyCanvas?.(true)
}
/**
* Removes chat history from a node
* @param node The graph node to remove the chat history from
*/
function removeChatHistory(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === CHAT_HISTORY_WIDGET_NAME
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showChatHistory,
removeChatHistory
}
}

View File

@@ -0,0 +1,103 @@
import type { LGraphNode } from '@comfyorg/litegraph'
// Direct mapping of node names to prices
export const NODE_PRICES: Record<string, string> = {
// OpenAI models
OpenAIDalle2: '$0.02/Run',
OpenAIDalle3: '$0.08/Run',
OpenAIGPTImage1: '$0.07/Run',
// Ideogram models
IdeogramV1: '$0.06/Run',
IdeogramV2: '$0.08/Run',
IdeogramV3: 'Variable pricing',
// Minimax models
MinimaxTextToVideoNode: '$0.43/Run',
MinimaxImageToVideoNode: '$0.43/Run',
// Google Veo
VeoVideoGenerationNode: '$5.0/Run',
// Kling models
KlingTextToVideoNode: 'Variable pricing',
KlingImage2VideoNode: 'Variable pricing',
KlingCameraControlI2VNode: '$0.49/Run',
KlingCameraControlT2VNode: '$0.14/Run',
KlingStartEndFrameNode: 'Variable pricing',
KlingVideoExtendNode: '$0.28/Run',
KlingLipSyncAudioToVideoNode: '$0.07/Run',
KlingLipSyncTextToVideoNode: '$0.07/Run',
KlingVirtualTryOnNode: '$0.07/Run',
KlingImageGenerationNode: 'Variable pricing',
KlingSingleImageVideoEffectNode: 'Variable pricing',
KlingDualCharacterVideoEffectNode: 'Variable pricing',
// Flux Pro models
FluxProUltraImageNode: '$0.06/Run',
FluxProExpandNode: '$0.05/Run',
FluxProFillNode: '$0.05/Run',
FluxProCannyNode: '$0.05/Run',
FluxProDepthNode: '$0.05/Run',
// Luma models
LumaVideoNode: 'Variable pricing',
LumaImageToVideoNode: 'Variable pricing',
LumaImageNode: 'Variable pricing',
LumaImageModifyNode: 'Variable pricing',
// Recraft models
RecraftTextToImageNode: '$0.04/Run',
RecraftImageToImageNode: '$0.04/Run',
RecraftImageInpaintingNode: '$0.04/Run',
RecraftTextToVectorNode: '$0.08/Run',
RecraftVectorizeImageNode: '$0.01/Run',
RecraftRemoveBackgroundNode: '$0.01/Run',
RecraftReplaceBackgroundNode: '$0.04/Run',
RecraftCrispUpscaleNode: '$0.004/Run',
RecraftCreativeUpscaleNode: '$0.004/Run',
// Pixverse models
PixverseTextToVideoNode: '$0.9/Run',
PixverseImageToVideoNode: '$0.9/Run',
PixverseTransitionVideoNode: '$0.9/Run',
// Stability models
StabilityStableImageUltraNode: '$0.08/Run',
StabilityStableImageSD_3_5Node: 'Variable pricing',
StabilityUpscaleConservativeNode: '$0.25/Run',
StabilityUpscaleCreativeNode: '$0.25/Run',
StabilityUpscaleFastNode: '$0.01/Run',
// Pika models
PikaImageToVideoNode2_2: 'Variable pricing',
PikaTextToVideoNode2_2: 'Variable pricing',
PikaScenesV2_2: 'Variable pricing',
PikaStartEndFrameNode2_2: 'Variable pricing',
Pikadditions: '$0.3/Run',
Pikaswaps: '$0.3/Run',
Pikaffects: '$0.45/Run'
}
/**
* Simple utility function to get the price for a node
* Returns a formatted price string or default value if the node isn't found
*/
export function getNodePrice(
node: LGraphNode,
defaultPrice = '0.02/Run (approx)'
): string {
if (!node.constructor.nodeData?.api_node) {
return ''
}
return NODE_PRICES[node.constructor.name] || defaultPrice
}
/**
* Composable to get node pricing information for API nodes
*/
export const useNodePricing = () => {
/**
* Get the price display for a node
*/
const getNodePriceDisplay = (node: LGraphNode): string => {
if (!node.constructor.nodeData?.api_node) {
return ''
}
return NODE_PRICES[node.constructor.name] || '0.02/Run (approx)'
}
return {
getNodePriceDisplay
}
}

View File

@@ -0,0 +1,53 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { useTextPreviewWidget } from '@/composables/widgets/useProgressTextWidget'
const TEXT_PREVIEW_WIDGET_NAME = '$$node-text-preview'
/**
* Composable for handling node text previews
*/
export function useNodeProgressText() {
const textPreviewWidget = useTextPreviewWidget()
const findTextPreviewWidget = (node: LGraphNode) =>
node.widgets?.find((w) => w.name === TEXT_PREVIEW_WIDGET_NAME)
const addTextPreviewWidget = (node: LGraphNode) =>
textPreviewWidget(node, {
name: TEXT_PREVIEW_WIDGET_NAME,
type: 'progressText'
})
/**
* Shows text preview for a node
* @param node The graph node to show the preview for
*/
function showTextPreview(node: LGraphNode, text: string) {
const widget = findTextPreviewWidget(node) ?? addTextPreviewWidget(node)
widget.value = text
node.setDirtyCanvas?.(true)
}
/**
* Removes text preview from a node
* @param node The graph node to remove the preview from
*/
function removeTextPreview(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === TEXT_PREVIEW_WIDGET_NAME
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showTextPreview,
removeTextPreview
}
}

View File

@@ -0,0 +1,53 @@
import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
const DEFAULT_TITLE = 'ComfyUI'
const TITLE_SUFFIX = ' - ComfyUI'
export const useBrowserTabTitle = () => {
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const executionText = computed(() =>
executionStore.isIdle
? ''
: `[${Math.round(executionStore.executionProgress * 100)}%]`
)
const newMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const isUnsavedText = computed(() =>
workflowStore.activeWorkflow?.isModified ||
!workflowStore.activeWorkflow?.isPersisted
? ' *'
: ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.filename
return workflowName
? isUnsavedText.value + workflowName + TITLE_SUFFIX
: DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
: ''
)
const workflowTitle = computed(
() =>
executionText.value +
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
)
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
useTitle(title)
}

View File

@@ -5,6 +5,7 @@ import {
LiteGraph
} from '@comfyorg/litegraph'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
@@ -13,7 +14,6 @@ import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
@@ -32,7 +32,7 @@ export function useCoreCommands(): ComfyCommand[] {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthService = useFirebaseAuthService()
const firebaseAuthActions = useFirebaseAuthActions()
const toastStore = useToastStore()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
@@ -320,7 +320,7 @@ export function useCoreCommands(): ComfyCommand[] {
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
const queueNodeIds = getSelectedNodes()
.filter((node) => node.constructor.nodeData.output_node)
.filter((node) => node.constructor.nodeData?.output_node)
.map((node) => node.id)
if (queueNodeIds.length === 0) {
toastStore.add({
@@ -671,7 +671,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Sign Out',
versionAdded: '1.18.1',
function: async () => {
await firebaseAuthService.logout()
await firebaseAuthActions.logout()
}
}
]

View File

@@ -128,4 +128,10 @@ export const useLitegraphSettings = () => {
'LiteGraph.Pointer.TrackpadGestures'
)
})
watchEffect(() => {
LiteGraph.saveViewportWithGraph = settingStore.get(
'Comfy.EnableWorkflowViewRestore'
)
})
}

View File

@@ -0,0 +1,23 @@
import { useFavicon } from '@vueuse/core'
import { watch } from 'vue'
import { useExecutionStore } from '@/stores/executionStore'
export const useProgressFavicon = () => {
const defaultFavicon = '/assets/images/favicon_progress_16x16/frame_9.png'
const favicon = useFavicon(defaultFavicon)
const executionStore = useExecutionStore()
const totalFrames = 10
watch(
[() => executionStore.executionProgress, () => executionStore.isIdle],
([progress, isIdle]) => {
if (isIdle) {
favicon.value = defaultFavicon
} else {
const frame = Math.floor(progress * totalFrames)
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
}
}
)
}

View File

@@ -1,7 +1,7 @@
import { watchDebounced } from '@vueuse/core'
import type { Hit } from 'algoliasearch/dist/lite/browser'
import { memoize, orderBy } from 'lodash'
import { computed, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import {
AlgoliaNodePack,
@@ -11,10 +11,10 @@ import {
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
const SEARCH_DEBOUNCE_TIME = 256
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',
@@ -30,7 +30,12 @@ const isDateField = (field: SortableAlgoliaField): boolean =>
/**
* Composable for managing UI state of Comfy Node Registry search.
*/
export function useRegistrySearch() {
export function useRegistrySearch(
options: {
maxCacheSize?: number
} = {}
) {
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
const isLoading = ref(false)
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
const searchMode = ref<'nodes' | 'packs'>('packs')
@@ -56,7 +61,10 @@ export function useRegistrySearch() {
: []
)
const { searchPacks, toRegistryPack } = useAlgoliaSearchService()
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
useAlgoliaSearchService({
maxCacheSize
})
const algoliaToRegistry = memoize(
toRegistryPack,
@@ -77,7 +85,7 @@ export function useRegistrySearch() {
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacks(
const { nodePacks, querySuggestions } = await searchPacksCached(
searchQuery.value,
{
pageSize: pageSize.value,
@@ -116,6 +124,8 @@ export function useRegistrySearch() {
immediate: true
})
onUnmounted(clearSearchPacksCache)
return {
isLoading,
pageNumber,

View File

@@ -0,0 +1,43 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
const PADDING = 16
export const useChatHistoryWidget = (
options: {
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
InstanceType<typeof ChatHistoryWidget>['$props']
>({
node,
name: inputSpec.name,
component: ChatHistoryWidget,
props: options.props,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => 400 + PADDING
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

@@ -8,6 +8,7 @@ import type {
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
@@ -16,7 +17,7 @@ const renderPreview = (
node: LGraphNode,
shiftY: number
) => {
const canvas = app.canvas
const canvas = useCanvasStore().getCanvas()
const mouse = canvas.graph_mouse
if (!canvas.pointer_is_down && node.pointerDown) {

View File

@@ -0,0 +1,39 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
const PADDING = 16
export const useTextPreviewWidget = (
options: {
minHeight?: number
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<string | object>({
node,
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

@@ -1,3 +1,7 @@
export const COMFY_API_BASE_URL = __USE_PROD_CONFIG__
? 'https://api.comfy.org'
: 'https://stagingapi.comfy.org'
export const COMFY_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
? 'https://platform.comfy.org'
: 'https://stagingplatform.comfy.org'

View File

@@ -0,0 +1,63 @@
const apiNodeNames = [
'IdeogramV1',
'IdeogramV2',
'IdeogramV3',
'MinimaxTextToVideoNode',
'MinimaxImageToVideoNode',
'VeoVideoGenerationNode',
'KlingCameraControls',
'KlingTextToVideoNode',
'KlingImage2VideoNode',
'KlingCameraControlI2VNode',
'KlingCameraControlT2VNode',
'KlingStartEndFrameNode',
'KlingVideoExtendNode',
'KlingLipSyncAudioToVideoNode',
'KlingLipSyncTextToVideoNode',
'KlingVirtualTryOnNode',
'KlingImageGenerationNode',
'KlingSingleImageVideoEffectNode',
'KlingDualCharacterVideoEffectNode',
'FluxProUltraImageNode',
'FluxProExpandNode',
'FluxProFillNode',
'FluxProCannyNode',
'FluxProDepthNode',
'LumaImageNode',
'LumaImageModifyNode',
'LumaVideoNode',
'LumaImageToVideoNode',
'LumaReferenceNode',
'LumaConceptsNode',
'RecraftTextToImageNode',
'RecraftImageToImageNode',
'RecraftImageInpaintingNode',
'RecraftTextToVectorNode',
'RecraftVectorizeImageNode',
'RecraftRemoveBackgroundNode',
'RecraftReplaceBackgroundNode',
'RecraftCrispUpscaleNode',
'RecraftCreativeUpscaleNode',
'RecraftStyleV3RealisticImage',
'RecraftStyleV3DigitalIllustration',
'RecraftStyleV3LogoRaster',
'RecraftStyleV3InfiniteStyleLibrary',
'RecraftColorRGB',
'RecraftControls',
'PixverseTextToVideoNode',
'PixverseImageToVideoNode',
'PixverseTransitionVideoNode',
'PixverseTemplateNode',
'StabilityStableImageUltraNode',
'StabilityStableImageSD_3_5Node',
'StabilityUpscaleConservativeNode',
'StabilityUpscaleCreativeNode',
'StabilityUpscaleFastNode',
'PikaImageToVideoNode2_2',
'PikaTextToVideoNode2_2',
'PikaScenesV2_2',
'Pikadditions',
'Pikaswaps',
'Pikaffects',
'PikaStartEndFrameNode2_2'
]

View File

@@ -0,0 +1,139 @@
# Core Extensions
This directory contains the core extensions that provide essential functionality to the ComfyUI frontend.
## Table of Contents
- [Overview](#overview)
- [Extension Architecture](#extension-architecture)
- [Core Extensions](#core-extensions)
- [Extension Development](#extension-development)
- [Extension Hooks](#extension-hooks)
- [Further Reading](#further-reading)
## Overview
Extensions in ComfyUI are modular JavaScript modules that extend and enhance the functionality of the frontend. The extensions in this directory are considered "core" as they provide fundamental features that are built into ComfyUI by default.
## Extension Architecture
ComfyUI's extension system follows these key principles:
1. **Registration-based:** Extensions must register themselves with the application using `app.registerExtension()`
2. **Hook-driven:** Extensions interact with the system through predefined hooks
3. **Non-intrusive:** Extensions should avoid directly modifying core objects where possible
## Core Extensions List
The core extensions include:
| Extension | Description |
|-----------|-------------|
| clipspace.ts | Implements the Clipspace feature for temporary image storage |
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities |
| groupNode.ts | Implements the group node functionality to organize workflows |
| load3d.ts | Supports 3D model loading and visualization |
| maskeditor.ts | Implements the mask editor for image masking operations |
| noteNode.ts | Adds note nodes for documentation within workflows |
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections |
| uploadImage.ts | Handles image upload functionality |
| webcamCapture.ts | Provides webcam capture capabilities |
| widgetInputs.ts | Implements various widget input types |
## Extension Development
When developing or modifying extensions, follow these best practices:
1. **Use provided hooks** rather than directly modifying core application objects
2. **Maintain compatibility** with other extensions
3. **Follow naming conventions** for both extension names and settings
4. **Properly document** extension hooks and functionality
5. **Test with other extensions** to ensure no conflicts
### Extension Registration
Extensions are registered using the `app.registerExtension()` method:
```javascript
app.registerExtension({
name: "MyExtension",
// Hook implementations
async init() {
// Implementation
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Implementation
}
// Other hooks as needed
});
```
## Extension Hooks
ComfyUI extensions can implement various hooks that are called at specific points in the application lifecycle:
### Hook Execution Sequence
#### Web Page Load
```
init
addCustomNodeDefs
getCustomWidgets
beforeRegisterNodeDef [repeated multiple times]
registerCustomNodes
beforeConfigureGraph
nodeCreated
loadedGraphNode
afterConfigureGraph
setup
```
#### Loading Workflow
```
beforeConfigureGraph
beforeRegisterNodeDef [zero, one, or multiple times]
nodeCreated [repeated multiple times]
loadedGraphNode [repeated multiple times]
afterConfigureGraph
```
#### Adding New Node
```
nodeCreated
```
### Key Hooks
| Hook | Description |
|------|-------------|
| `init` | Called after canvas creation but before nodes are added |
| `setup` | Called after the application is fully set up and running |
| `addCustomNodeDefs` | Called before nodes are registered with the graph |
| `getCustomWidgets` | Allows extensions to add custom widgets |
| `beforeRegisterNodeDef` | Allows extensions to modify nodes before registration |
| `registerCustomNodes` | Allows extensions to register additional nodes |
| `loadedGraphNode` | Called when a node is reloaded onto the graph |
| `nodeCreated` | Called after a node's constructor |
| `beforeConfigureGraph` | Called before a graph is configured |
| `afterConfigureGraph` | Called after a graph is configured |
| `getSelectionToolboxCommands` | Allows extensions to add commands to the selection toolbox |
For the complete list of available hooks and detailed descriptions, see the [ComfyExtension interface in comfy.ts](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/types/comfy.ts).
## Further Reading
For more detailed information about ComfyUI's extension system, refer to the official documentation:
- [JavaScript Extension Overview](https://docs.comfy.org/custom-nodes/js/javascript_overview)
- [JavaScript Hooks](https://docs.comfy.org/custom-nodes/js/javascript_hooks)
- [JavaScript Objects and Hijacking](https://docs.comfy.org/custom-nodes/js/javascript_objects_and_hijacking)
- [JavaScript Settings](https://docs.comfy.org/custom-nodes/js/javascript_settings)
- [JavaScript Examples](https://docs.comfy.org/custom-nodes/js/javascript_examples)
Also, check the main [README.md](https://github.com/Comfy-Org/ComfyUI_frontend#developer-apis) section on Developer APIs for the latest information on extension APIs and features.

View File

@@ -797,7 +797,7 @@ export class GroupNodeConfig {
export class GroupNodeHandler {
node: LGraphNode
groupData
groupData: any
innerNodes: any
constructor(node: LGraphNode) {

View File

@@ -82,6 +82,8 @@ export class SceneManager implements SceneManagerInterface {
}
async setBackgroundImage(uploadPath: string): Promise<void> {
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
if (uploadPath === '') {
this.removeBackgroundImage()
return
@@ -123,7 +125,9 @@ export class SceneManager implements SceneManagerInterface {
)
this.eventManager.emitEvent('backgroundImageChange', uploadPath)
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
} catch (error) {
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
console.error('Error loading background image:', error)
}
}
@@ -139,6 +143,7 @@ export class SceneManager implements SceneManagerInterface {
this.backgroundTexture.dispose()
this.backgroundTexture = null
}
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
}
updateBackgroundSize(

View File

@@ -401,6 +401,7 @@ app.registerExtension({
// @ts-expect-error
data.groupNodes = {}
}
if (nodeData == null) throw new TypeError('nodeData is not set')
// @ts-expect-error
data.groupNodes[nodeData.name] = groupData
// @ts-expect-error

View File

@@ -3,6 +3,7 @@ Preview Any - original implement from
https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
*/
import { app } from '@/scripts/app'
import { DOMWidget } from '@/scripts/domWidget'
import { ComfyWidgets } from '@/scripts/widgets'
import { useExtensionService } from '@/services/extensionService'

View File

@@ -117,7 +117,10 @@ app.registerExtension({
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.serialize = false
const isOutputNode = node.constructor.nodeData.output_node
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')
const isOutputNode = nodeData.output_node
if (isOutputNode) {
// Hide the audio widget when there is no audio initially.
audioUIWidget.element.classList.add('empty-audio-widget')

View File

@@ -114,7 +114,9 @@
"learnMore": "Learn more",
"amount": "Amount",
"unknownError": "Unknown error",
"title": "Title"
"title": "Title",
"edit": "Edit",
"copy": "Copy"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -1259,7 +1261,9 @@
"failedToInitiateCreditPurchase": "Failed to initiate credit purchase: {error}",
"failedToAccessBillingPortal": "Failed to access billing portal: {error}",
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist."
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
"nothingSelected": "Nothing selected"
},
"auth": {
"apiKey": {
@@ -1380,30 +1384,17 @@
"notSet": "Not set",
"updatePassword": "Update Password"
},
"apiNodesNews": {
"introducing": "Introducing",
"subtitle": "Access all the popular paid models natively in ComfyUI",
"steps": {
"step1": {
"title": "Login/Create an account:",
"subtitle": "Settings > User > Login"
},
"step2": {
"title": "Purchase credits:",
"subtitle": "Settings > Credits > Buy Credits"
},
"step3": {
"title": "Locate new API Nodes under 'API Node' section and add to the canvas"
},
"step4": {
"title": "Run!"
}
}
},
"selectionToolbox": {
"executeButton": {
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
"disabledTooltip": "No output nodes selected"
}
},
"chatHistory": {
"cancelEdit": "Cancel",
"editTooltip": "Edit message",
"cancelEditTooltip": "Cancel edit",
"copiedTooltip": "Copied",
"copyTooltip": "Copy message to clipboard"
}
}

View File

@@ -4,26 +4,6 @@
"title": "Nodo(s) de API",
"totalCost": "Costo total"
},
"apiNodesNews": {
"introducing": "Presentamos",
"steps": {
"step1": {
"subtitle": "Configuración > Usuario > Iniciar sesión",
"title": "Inicia sesión/Crea una cuenta:"
},
"step2": {
"subtitle": "Configuración > Créditos > Comprar créditos",
"title": "Compra créditos:"
},
"step3": {
"title": "Ubica los nuevos nodos API en la sección 'API Node' y agrégalos al lienzo"
},
"step4": {
"title": "¡Ejecuta!"
}
},
"subtitle": "Todos los modelos externos ahora disponibles en ComfyUI"
},
"apiNodesSignInDialog": {
"message": "Este flujo de trabajo contiene nodos de API, que requieren que inicies sesión en tu cuenta para poder ejecutar.",
"title": "Se requiere iniciar sesión para usar los nodos de API"
@@ -102,6 +82,13 @@
"title": "Crea una cuenta"
}
},
"chatHistory": {
"cancelEdit": "Cancelar",
"cancelEditTooltip": "Cancelar edición",
"copiedTooltip": "Copiado",
"copyTooltip": "Copiar mensaje al portapapeles",
"editTooltip": "Editar mensaje"
},
"clipboard": {
"errorMessage": "Error al copiar al portapapeles",
"errorNotSupported": "API del portapapeles no soportada en su navegador",
@@ -277,6 +264,7 @@
"continue": "Continuar",
"control_after_generate": "control después de generar",
"control_before_generate": "control antes de generar",
"copy": "Copiar",
"copyToClipboard": "Copiar al portapapeles",
"currentUser": "Usuario actual",
"customize": "Personalizar",
@@ -288,6 +276,7 @@
"disableAll": "Deshabilitar todo",
"disabling": "Deshabilitando",
"download": "Descargar",
"edit": "Editar",
"empty": "Vacío",
"enableAll": "Habilitar todo",
"enabled": "Habilitado",
@@ -1354,6 +1343,7 @@
"no3dSceneToExport": "No hay escena 3D para exportar",
"noTemplatesToExport": "No hay plantillas para exportar",
"nodeDefinitionsUpdated": "Definiciones de nodos actualizadas",
"nothingSelected": "Nada seleccionado",
"nothingToGroup": "Nada para agrupar",
"nothingToQueue": "Nada para poner en cola",
"pendingTasksDeleted": "Tareas pendientes eliminadas",
@@ -1362,6 +1352,7 @@
"unableToGetModelFilePath": "No se puede obtener la ruta del archivo del modelo",
"unauthorizedDomain": "Tu dominio {domain} no está autorizado para usar este servicio. Por favor, contacta a {email} para agregar tu dominio a la lista blanca.",
"updateRequested": "Actualización solicitada",
"useApiKeyTip": "Consejo: ¿No puedes acceder al inicio de sesión normal? Usa la opción de clave API de Comfy.",
"userNotAuthenticated": "Usuario no autenticado"
},
"userSelect": {

View File

@@ -4,26 +4,6 @@
"title": "Nœud(s) API",
"totalCost": "Coût total"
},
"apiNodesNews": {
"introducing": "Présentation",
"steps": {
"step1": {
"subtitle": "Paramètres > Utilisateur > Connexion",
"title": "Connectez-vous / Créez un compte :"
},
"step2": {
"subtitle": "Paramètres > Crédits > Acheter des crédits",
"title": "Achetez des crédits :"
},
"step3": {
"title": "Trouvez les nouveaux nœuds API dans la section 'API Node' et ajoutez-les à la toile"
},
"step4": {
"title": "Lancez !"
}
},
"subtitle": "Tous les modèles externes sont désormais disponibles dans ComfyUI"
},
"apiNodesSignInDialog": {
"message": "Ce flux de travail contient des nœuds API, qui nécessitent que vous soyez connecté à votre compte pour pouvoir fonctionner.",
"title": "Connexion requise pour utiliser les nœuds API"
@@ -102,6 +82,13 @@
"title": "Créer un compte"
}
},
"chatHistory": {
"cancelEdit": "Annuler",
"cancelEditTooltip": "Annuler la modification",
"copiedTooltip": "Copié",
"copyTooltip": "Copier le message dans le presse-papiers",
"editTooltip": "Modifier le message"
},
"clipboard": {
"errorMessage": "Échec de la copie dans le presse-papiers",
"errorNotSupported": "L'API du presse-papiers n'est pas prise en charge par votre navigateur",
@@ -277,6 +264,7 @@
"continue": "Continuer",
"control_after_generate": "contrôle après génération",
"control_before_generate": "contrôle avant génération",
"copy": "Copier",
"copyToClipboard": "Copier dans le presse-papiers",
"currentUser": "Utilisateur actuel",
"customize": "Personnaliser",
@@ -288,6 +276,7 @@
"disableAll": "Désactiver tout",
"disabling": "Désactivation",
"download": "Télécharger",
"edit": "Modifier",
"empty": "Vide",
"enableAll": "Activer tout",
"enabled": "Activé",
@@ -1354,6 +1343,7 @@
"no3dSceneToExport": "Aucune scène 3D à exporter",
"noTemplatesToExport": "Aucun modèle à exporter",
"nodeDefinitionsUpdated": "Définitions de nœuds mises à jour",
"nothingSelected": "Aucune sélection",
"nothingToGroup": "Rien à regrouper",
"nothingToQueue": "Rien à ajouter à la file dattente",
"pendingTasksDeleted": "Tâches en attente supprimées",
@@ -1362,6 +1352,7 @@
"unableToGetModelFilePath": "Impossible d'obtenir le chemin du fichier modèle",
"unauthorizedDomain": "Votre domaine {domain} n'est pas autorisé à utiliser ce service. Veuillez contacter {email} pour ajouter votre domaine à la liste blanche.",
"updateRequested": "Mise à jour demandée",
"useApiKeyTip": "Astuce : Vous ne pouvez pas accéder à la connexion normale ? Utilisez loption Clé API Comfy.",
"userNotAuthenticated": "Utilisateur non authentifié"
},
"userSelect": {

View File

@@ -4,26 +4,6 @@
"title": "APIード",
"totalCost": "合計コスト"
},
"apiNodesNews": {
"introducing": "紹介",
"steps": {
"step1": {
"subtitle": "設定 > ユーザー > ログイン",
"title": "ログイン/アカウント作成:"
},
"step2": {
"subtitle": "設定 > クレジット > クレジットを購入",
"title": "クレジットを購入:"
},
"step3": {
"title": "「APIード」セクションで新しいAPIードを見つけてキャンバスに追加"
},
"step4": {
"title": "実行!"
}
},
"subtitle": "すべての外部モデルがComfyUIで利用可能になりました"
},
"apiNodesSignInDialog": {
"message": "このワークフローにはAPIードが含まれており、実行するためにはアカウントにサインインする必要があります。",
"title": "APIードを使用するためにはサインインが必要です"
@@ -102,6 +82,13 @@
"title": "アカウントを作成する"
}
},
"chatHistory": {
"cancelEdit": "キャンセル",
"cancelEditTooltip": "編集をキャンセル",
"copiedTooltip": "コピーしました",
"copyTooltip": "メッセージをクリップボードにコピー",
"editTooltip": "メッセージを編集"
},
"clipboard": {
"errorMessage": "クリップボードへのコピーに失敗しました",
"errorNotSupported": "お使いのブラウザではクリップボードAPIがサポートされていません",
@@ -277,6 +264,7 @@
"continue": "続ける",
"control_after_generate": "生成後の制御",
"control_before_generate": "生成前の制御",
"copy": "コピー",
"copyToClipboard": "クリップボードにコピー",
"currentUser": "現在のユーザー",
"customize": "カスタマイズ",
@@ -288,6 +276,7 @@
"disableAll": "すべて無効にする",
"disabling": "無効化",
"download": "ダウンロード",
"edit": "編集",
"empty": "空",
"enableAll": "すべて有効にする",
"enabled": "有効",
@@ -1354,6 +1343,7 @@
"no3dSceneToExport": "エクスポートする3Dシーンがありません",
"noTemplatesToExport": "エクスポートするテンプレートがありません",
"nodeDefinitionsUpdated": "ノード定義が更新されました",
"nothingSelected": "選択されていません",
"nothingToGroup": "グループ化するものがありません",
"nothingToQueue": "キューに追加する項目がありません",
"pendingTasksDeleted": "保留中のタスクが削除されました",
@@ -1362,6 +1352,7 @@
"unableToGetModelFilePath": "モデルファイルのパスを取得できません",
"unauthorizedDomain": "あなたのドメイン {domain} はこのサービスを利用する権限がありません。ご利用のドメインをホワイトリストに追加するには、{email} までご連絡ください。",
"updateRequested": "更新が要求されました",
"useApiKeyTip": "ヒント通常のログインにアクセスできませんかComfy APIキーオプションを使用してください。",
"userNotAuthenticated": "ユーザーが認証されていません"
},
"userSelect": {

View File

@@ -4,26 +4,6 @@
"title": "API 노드(들)",
"totalCost": "총 비용"
},
"apiNodesNews": {
"introducing": "소개합니다",
"steps": {
"step1": {
"subtitle": "설정 > 사용자 > 로그인",
"title": "로그인/계정 생성:"
},
"step2": {
"subtitle": "설정 > 크레딧 > 크레딧 구매",
"title": "크레딧 구매:"
},
"step3": {
"title": "'API Node' 섹션에서 새로운 API 노드를 찾아 캔버스에 추가하세요"
},
"step4": {
"title": "실행!"
}
},
"subtitle": "모든 외부 모델이 이제 ComfyUI에서 사용 가능합니다"
},
"apiNodesSignInDialog": {
"message": "이 워크플로우에는 API 노드가 포함되어 있으며, 실행하려면 계정에 로그인해야 합니다.",
"title": "API 노드 사용에 필요한 로그인"
@@ -102,6 +82,13 @@
"title": "계정 생성"
}
},
"chatHistory": {
"cancelEdit": "취소",
"cancelEditTooltip": "편집 취소",
"copiedTooltip": "복사됨",
"copyTooltip": "메시지를 클립보드에 복사",
"editTooltip": "메시지 편집"
},
"clipboard": {
"errorMessage": "클립보드에 복사하지 못했습니다",
"errorNotSupported": "브라우저가 클립보드 API를 지원하지 않습니다.",
@@ -277,6 +264,7 @@
"continue": "계속",
"control_after_generate": "생성 후 제어",
"control_before_generate": "생성 전 제어",
"copy": "복사",
"copyToClipboard": "클립보드에 복사",
"currentUser": "현재 사용자",
"customize": "사용자 정의",
@@ -288,6 +276,7 @@
"disableAll": "모두 비활성화",
"disabling": "비활성화 중",
"download": "다운로드",
"edit": "편집",
"empty": "비어 있음",
"enableAll": "모두 활성화",
"enabled": "활성화됨",
@@ -655,20 +644,20 @@
"Tolerance": "허용 오차"
},
"menu": {
"autoQueue": "자동 실행 ",
"autoQueue": "자동 실행 대기열",
"batchCount": "배치 수",
"batchCountTooltip": "워크플로 작업을 실행 에 반복 추가할 횟수",
"batchCountTooltip": "워크플로 작업을 실행 대기열에 반복 추가할 횟수",
"clear": "워크플로 비우기",
"clipspace": "클립스페이스 열기",
"disabled": "비활성화됨",
"disabledTooltip": "워크플로 작업을 자동으로 실행 에 추가하지 않습니다.",
"disabledTooltip": "워크플로 작업을 자동으로 실행 대기열에 추가하지 않습니다.",
"execute": "실행",
"hideMenu": "메뉴 숨기기",
"instant": "즉시",
"instantTooltip": "워크플로 실행이 완료되면 즉시 실행 에 추가합니다.",
"instantTooltip": "워크플로 실행이 완료되면 즉시 실행 대기열에 추가합니다.",
"interrupt": "현재 실행 취소",
"onChange": "변경 시",
"onChangeTooltip": "변경이 있는 경우에만 워크플로를 실행 에 추가합니다.",
"onChangeTooltip": "변경이 있는 경우에만 워크플로를 실행 대기열에 추가합니다.",
"refresh": "노드 정의 새로 고침",
"resetView": "캔버스 보기 재설정",
"run": "실행",
@@ -725,8 +714,8 @@
"Pin/Unpin Selected Items": "선택한 항목 고정/고정 해제",
"Pin/Unpin Selected Nodes": "선택한 노드 고정/고정 해제",
"Previous Opened Workflow": "이전 열린 워크플로",
"Queue Prompt": "실행 에 프롬프트 추가",
"Queue Prompt (Front)": "실행 맨 앞에 프롬프트 추가",
"Queue Prompt": "실행 대기열에 프롬프트 추가",
"Queue Prompt (Front)": "실행 대기열 맨 앞에 프롬프트 추가",
"Queue Selected Output Nodes": "선택한 출력 노드 대기열에 추가",
"Quit": "종료",
"Redo": "다시 실행",
@@ -745,7 +734,7 @@
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Progress Dialog": "진행 상황 대화 상자 전환",
"Toggle Queue Sidebar": "실행 사이드바 전환",
"Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
@@ -816,7 +805,7 @@
"photomaker": "포토메이커",
"postprocessing": "후처리",
"preprocessors": "전처리기",
"primitive": "프리미티브",
"primitive": "기본 입력",
"samplers": "샘플러",
"sampling": "샘플링",
"schedulers": "스케줄러",
@@ -1041,8 +1030,8 @@
"Node Widget": "노드 위젯",
"NodeLibrary": "노드 라이브러리",
"Pointer": "포인터",
"Queue": "실행 ",
"QueueButton": "실행 버튼",
"Queue": "실행 대기열",
"QueueButton": "실행 대기열 버튼",
"Reroute": "경유점",
"RerouteBeta": "경유점 (베타)",
"Scene": "장면",
@@ -1068,7 +1057,7 @@
"sortOrder": "정렬 순서"
},
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
"queue": "실행 ",
"queue": "실행 대기열",
"queueTab": {
"backToAllTasks": "모든 작업으로 돌아가기",
"clearPendingTasks": "보류 중인 작업 지우기",
@@ -1259,28 +1248,28 @@
"mixing_controlnets": "여러 ControlNet 모델을 결합합니다."
},
"Flux": {
"flux_canny_model_example": "에지 감지로부터 이미지를 생성합니다.",
"flux_depth_lora_example": "깊이 인식 LoRA 이미지를 생성합니다.",
"flux_dev_checkpoint_example": "Flux 개발 모델로 이미지를 생성합니다.",
"flux_canny_model_example": "검출된 경계선으로 이미지를 생성합니다.",
"flux_depth_lora_example": "깊이 인식 LoRA 를 이용해 이미지를 생성합니다.",
"flux_dev_checkpoint_example": "FLUX Dev 모델로 이미지를 생성합니다.",
"flux_fill_inpaint_example": "이미지의 누락된 부분을 채웁니다.",
"flux_fill_outpaint_example": "Flux 아웃페인팅으로 이미지를 확장합니다.",
"flux_fill_outpaint_example": "FLUX 아웃페인팅으로 이미지를 확장합니다.",
"flux_redux_model_example": "참조 이미지의 스타일을 가이드 이미지 생성에 적용합니다.",
"flux_schnell": "Flux Schnell로 이미지를 빠르게 생성합니다."
"flux_schnell": "FLUX Schnell 모델로 이미지를 빠르게 생성합니다."
},
"Image": {
"hidream_e1_full": "HiDream E1로 이미지를 편집합니다.",
"hidream_i1_dev": "HiDream I1 Dev로 이미지를 생성합니다.",
"hidream_i1_fast": "HiDream I1로 이미지를 빠르게 생성합니다.",
"hidream_i1_full": "HiDream I1로 이미지를 생성합니다.",
"sd3_5_large_blur": "SD 3.5로 흐릿한 참조 이미지에서 이미지를 생성합니다.",
"sd3_5_large_canny_controlnet_example": "SD 3.5에서 에지 감지로 이미지 생성을 가이드합니다.",
"sd3_5_large_depth": "SD 3.5로 깊이 인식 이미지 생성합니다.",
"sd3_5_simple_example": "SD 3.5로 이미지를 생성합니다.",
"hidream_e1_full": "HiDream E1 모델로 이미지를 편집합니다.",
"hidream_i1_dev": "HiDream I1 Dev 모델로 이미지를 생성합니다.",
"hidream_i1_fast": "HiDream I1 Fast 모델로 이미지를 빠르게 생성합니다.",
"hidream_i1_full": "HiDream I1 Full 모델로 이미지를 생성합니다.",
"sd3_5_large_blur": "SD 3.5 모델로 흐릿한 참조 이미지에서 이미지를 생성합니다.",
"sd3_5_large_canny_controlnet_example": "Canny 에지 이미지를 통해 SD 3.5 모델 이미지 생성을 가이드합니다.",
"sd3_5_large_depth": "깊이 인식 이미지를 통해 SD 3.5 모델 이미지 생성을 가이드합니다.",
"sd3_5_simple_example": "SD 3.5 모델로 이미지를 생성합니다.",
"sdxl_refiner_prompt_example": "SDXL 결과물을 리파이너로 향상시킵니다.",
"sdxl_revision_text_prompts": "참조 이미지의 개념을 SDXL 이미지 생성에 적용합니다.",
"sdxl_revision_zero_positive": "참조 이미지와 함께 텍스트 프롬프트를 추가하여 SDXL 이미지 생성을 가이드합니다.",
"sdxl_simple_example": "SDXL로 고품질 이미지를 생성합니다.",
"sdxlturbo_example": "SDXL Turbo로 한 번에 이미지를 생성합니다."
"sdxl_simple_example": "SDXL 모델로 고품질 이미지를 생성합니다.",
"sdxlturbo_example": "SDXL Turbo 모델로 1 스텝으로 이미지를 생성합니다."
},
"Image API": {
"api-openai-dall-e-2-inpaint": "Dall-E 2 API로 이미지를 인페인팅합니다.",
@@ -1354,6 +1343,7 @@
"no3dSceneToExport": "내보낼 3D 장면이 없습니다",
"noTemplatesToExport": "내보낼 템플릿이 없습니다",
"nodeDefinitionsUpdated": "노드 정의가 업데이트되었습니다",
"nothingSelected": "선택된 항목이 없습니다",
"nothingToGroup": "그룹화할 항목이 없습니다",
"nothingToQueue": "대기열에 추가할 항목이 없습니다",
"pendingTasksDeleted": "보류 중인 작업이 삭제되었습니다",
@@ -1362,6 +1352,7 @@
"unableToGetModelFilePath": "모델 파일 경로를 가져올 수 없습니다",
"unauthorizedDomain": "귀하의 도메인 {domain}은(는) 이 서비스를 사용할 수 있는 권한이 없습니다. 도메인을 허용 목록에 추가하려면 {email}로 문의해 주세요.",
"updateRequested": "업데이트 요청됨",
"useApiKeyTip": "팁: 일반 로그인을 사용할 수 없나요? Comfy API Key 옵션을 사용하세요.",
"userNotAuthenticated": "사용자가 인증되지 않았습니다"
},
"userSelect": {

File diff suppressed because it is too large Load Diff

View File

@@ -4,26 +4,6 @@
"title": "API Node(s)",
"totalCost": "Общая стоимость"
},
"apiNodesNews": {
"introducing": "Представляем",
"steps": {
"step1": {
"subtitle": "Настройки > Пользователь > Войти",
"title": "Войти/Создать аккаунт:"
},
"step2": {
"subtitle": "Настройки > Кредиты > Купить кредиты",
"title": "Купить кредиты:"
},
"step3": {
"title": "Найдите новые API-узлы в разделе 'API Node' и добавьте их на холст"
},
"step4": {
"title": "Запустить!"
}
},
"subtitle": "Все внешние модели теперь доступны в ComfyUI"
},
"apiNodesSignInDialog": {
"message": "Этот рабочий процесс содержит API Nodes, которые требуют входа в вашу учетную запись для выполнения.",
"title": "Требуется вход для использования API Nodes"
@@ -102,6 +82,13 @@
"title": "Создать аккаунт"
}
},
"chatHistory": {
"cancelEdit": "Отмена",
"cancelEditTooltip": "Отменить редактирование",
"copiedTooltip": "Скопировано",
"copyTooltip": "Скопировать сообщение в буфер",
"editTooltip": "Редактировать сообщение"
},
"clipboard": {
"errorMessage": "Не удалось скопировать в буфер обмена",
"errorNotSupported": "API буфера обмена не поддерживается в вашем браузере",
@@ -277,6 +264,7 @@
"continue": "Продолжить",
"control_after_generate": "управление после генерации",
"control_before_generate": "управление до генерации",
"copy": "Копировать",
"copyToClipboard": "Скопировать в буфер обмена",
"currentUser": "Текущий пользователь",
"customize": "Настроить",
@@ -288,6 +276,7 @@
"disableAll": "Отключить все",
"disabling": "Отключение",
"download": "Скачать",
"edit": "Редактировать",
"empty": "Пусто",
"enableAll": "Включить все",
"enabled": "Включено",
@@ -1354,6 +1343,7 @@
"no3dSceneToExport": "Нет 3D сцены для экспорта",
"noTemplatesToExport": "Нет шаблонов для экспорта",
"nodeDefinitionsUpdated": "Определения узлов обновлены",
"nothingSelected": "Ничего не выбрано",
"nothingToGroup": "Нечего группировать",
"nothingToQueue": "Нет заданий в очереди",
"pendingTasksDeleted": "Ожидающие задачи удалены",
@@ -1362,6 +1352,7 @@
"unableToGetModelFilePath": "Не удалось получить путь к файлу модели",
"unauthorizedDomain": "Ваш домен {domain} не авторизован для использования этого сервиса. Пожалуйста, свяжитесь с {email}, чтобы добавить ваш домен в белый список.",
"updateRequested": "Запрошено обновление",
"useApiKeyTip": "Совет: Нет доступа к обычному входу? Используйте опцию Comfy API Key.",
"userNotAuthenticated": "Пользователь не аутентифицирован"
},
"userSelect": {

View File

@@ -4,26 +4,6 @@
"title": "API节点",
"totalCost": "总成本"
},
"apiNodesNews": {
"introducing": "介绍",
"steps": {
"step1": {
"subtitle": "设置 > 用户 > 登录",
"title": "登录/创建账户:"
},
"step2": {
"subtitle": "设置 > 积分 > 购买积分",
"title": "购买积分:"
},
"step3": {
"title": "在“API 节点”部分找到新的 API 节点并添加到画布"
},
"step4": {
"title": "运行!"
}
},
"subtitle": "所有外部模型现已在 ComfyUI 中可用"
},
"apiNodesSignInDialog": {
"message": "此工作流包含API节点需要您登录账户才能运行。",
"title": "使用API节点需要登录"
@@ -102,6 +82,13 @@
"title": "创建一个账户"
}
},
"chatHistory": {
"cancelEdit": "取消",
"cancelEditTooltip": "取消编辑",
"copiedTooltip": "已复制",
"copyTooltip": "复制消息到剪贴板",
"editTooltip": "编辑消息"
},
"clipboard": {
"errorMessage": "复制到剪贴板失败",
"errorNotSupported": "您的浏览器不支持剪贴板API",
@@ -277,6 +264,7 @@
"continue": "继续",
"control_after_generate": "生成后控制",
"control_before_generate": "生成前控制",
"copy": "复制",
"copyToClipboard": "复制到剪贴板",
"currentUser": "当前用户",
"customize": "自定义",
@@ -288,6 +276,7 @@
"disableAll": "禁用全部",
"disabling": "禁用中",
"download": "下载",
"edit": "编辑",
"empty": "空",
"enableAll": "启用全部",
"enabled": "已启用",
@@ -1354,6 +1343,7 @@
"no3dSceneToExport": "没有3D场景可以导出",
"noTemplatesToExport": "没有模板可以导出",
"nodeDefinitionsUpdated": "节点定义已更新",
"nothingSelected": "未选择任何内容",
"nothingToGroup": "没有可分组的内容",
"nothingToQueue": "没有可加入队列的内容",
"pendingTasksDeleted": "待处理任务已删除",
@@ -1362,6 +1352,7 @@
"unableToGetModelFilePath": "无法获取模型文件路径",
"unauthorizedDomain": "您的域名 {domain} 未被授权使用此服务。请联系 {email} 将您的域名添加到白名单。",
"updateRequested": "已请求更新",
"useApiKeyTip": "提示:无法正常登录?请使用 Comfy API Key 选项。",
"userNotAuthenticated": "用户未认证"
},
"userSelect": {

View File

@@ -82,6 +82,17 @@ const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
current_outputs: z.any()
})
const zProgressTextWsMessage = z.object({
nodeId: zNodeId,
text: z.string()
})
const zDisplayComponentWsMessage = z.object({
node_id: zNodeId,
component: z.enum(['ChatHistoryWidget']),
props: z.record(z.string(), z.any()).optional()
})
const zTerminalSize = z.object({
cols: z.number(),
row: z.number()
@@ -114,6 +125,10 @@ export type ExecutionInterruptedWsMessage = z.infer<
>
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
export type DisplayComponentWsMessage = z.infer<
typeof zDisplayComponentWsMessage
>
// End of ws messages
const zPromptInputItem = z.object({
@@ -451,7 +466,7 @@ const zSettings = z.object({
'Comfy.Load3D.CameraType': z.enum(['perspective', 'orthographic']),
'pysssss.SnapToGrid': z.boolean(),
/** VHS setting is used for queue video preview support. */
'VHS.AdvancedPreviews': z.boolean(),
'VHS.AdvancedPreviews': z.string(),
/** Settings used for testing */
'test.setting': z.any(),
'main.sub.setting.name': z.any(),

View File

@@ -216,6 +216,7 @@ const zComfyNode = z
const zGroup = z
.object({
id: z.number().optional(),
title: z.string(),
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
color: z.string().optional(),

View File

@@ -1,6 +1,7 @@
import axios from 'axios'
import type {
DisplayComponentWsMessage,
EmbeddingsResponse,
ExecutedWsMessage,
ExecutingWsMessage,
@@ -14,6 +15,7 @@ import type {
LogsRawResponse,
LogsWsMessage,
PendingTaskItem,
ProgressTextWsMessage,
ProgressWsMessage,
PromptResponse,
RunningTaskItem,
@@ -101,6 +103,8 @@ interface BackendApiCalls {
logs: LogsWsMessage
/** Binary preview/progress data */
b_preview: Blob
progress_text: ProgressTextWsMessage
display_component: DisplayComponentWsMessage
}
/** Dictionary of all api calls */
@@ -399,12 +403,21 @@ export class ComfyApi extends EventTarget {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data)
const eventType = view.getUint32(0)
const imageType = view.getUint32(4)
const imageData = event.data.slice(8)
let imageMime
switch (eventType) {
case 3:
const decoder = new TextDecoder()
const data = event.data.slice(4)
const nodeIdLength = view.getUint32(4)
this.dispatchCustomEvent('progress_text', {
nodeId: decoder.decode(data.slice(4, 4 + nodeIdLength)),
text: decoder.decode(data.slice(4 + nodeIdLength))
})
break
case 1:
const imageType = view.getUint32(4)
const imageData = event.data.slice(8)
switch (imageType) {
case 2:
imageMime = 'image/png'

View File

@@ -25,7 +25,11 @@ import {
type ModelFile,
type NodeId
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import {
type ComfyNodeDef as ComfyNodeDefV1,
isComboInputSpecV1,
isComboInputSpecV2
} from '@/schemas/nodeDefSchema'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
@@ -139,13 +143,20 @@ export class ComfyApp {
_nodeOutputs: Record<string, any>
nodePreviewImages: Record<string, string[]>
// @ts-expect-error fixme ts strict error
graph: LGraph
#graph: LGraph
get graph() {
return this.#graph
}
// @ts-expect-error fixme ts strict error
canvas: LGraphCanvas
dragOverNode: LGraphNode | null = null
// @ts-expect-error fixme ts strict error
canvasEl: HTMLCanvasElement
configuringGraph: boolean = false
#configuringGraphLevel: number = 0
get configuringGraph() {
return this.#configuringGraphLevel > 0
}
// @ts-expect-error fixme ts strict error
ctx: CanvasRenderingContext2D
bodyTop: HTMLElement
@@ -688,17 +699,16 @@ export class ComfyApp {
api.init()
}
/** Flag that the graph is configuring to prevent nodes from running checks while its still loading */
#addConfigureHandler() {
const app = this
const configure = LGraph.prototype.configure
// Flag that the graph is configuring to prevent nodes from running checks while its still loading
LGraph.prototype.configure = function () {
app.configuringGraph = true
LGraph.prototype.configure = function (...args) {
app.#configuringGraphLevel++
try {
// @ts-expect-error fixme ts strict error
return configure.apply(this, arguments)
return configure.apply(this, args)
} finally {
app.configuringGraph = false
app.#configuringGraphLevel--
}
}
}
@@ -752,14 +762,13 @@ export class ComfyApp {
this.#addConfigureHandler()
this.#addApiUpdateHandlers()
this.graph = new LGraph()
this.#graph = new LGraph()
this.#addAfterConfigureHandler()
this.canvas = new LGraphCanvas(canvasEl, this.graph)
// Make canvas states reactive so we can observe changes on them.
this.canvas.state = reactive(this.canvas.state)
this.canvas.ds.state = reactive(this.canvas.ds.state)
// @ts-expect-error fixme ts strict error
this.ctx = canvasEl.getContext('2d')
@@ -1086,6 +1095,7 @@ export class ComfyApp {
title: t('errorDialog.loadWorkflowTitle'),
reportType: 'loadWorkflowError'
})
console.error(error)
return
}
for (const node of this.graph.nodes) {
@@ -1225,6 +1235,7 @@ export class ComfyApp {
title: t('errorDialog.promptExecutionError'),
reportType: 'promptExecutionError'
})
console.error(error)
if (error instanceof PromptExecutionError) {
executionStore.lastNodeErrors = error.response.node_errors ?? null
@@ -1572,12 +1583,26 @@ export class ComfyApp {
if (!def?.input) continue
if (node.widgets) {
const nodeInputs = def.input
for (const widget of node.widgets) {
if (widget.type === 'combo') {
if (def['input'].required?.[widget.name] !== undefined) {
widget.options.values = def['input'].required[widget.name][0]
} else if (def['input'].optional?.[widget.name] !== undefined) {
widget.options.values = def['input'].optional[widget.name][0]
let inputType: 'required' | 'optional' | undefined
if (nodeInputs.required?.[widget.name] !== undefined) {
inputType = 'required'
} else if (nodeInputs.optional?.[widget.name] !== undefined) {
inputType = 'optional'
}
if (inputType !== undefined) {
// Get the input spec associated with the widget
const inputSpec = nodeInputs[inputType]?.[widget.name]
if (inputSpec) {
// Refresh the combo widget's options with the values from the input spec
if (isComboInputSpecV2(inputSpec)) {
widget.options.values = inputSpec[1]?.options
} else if (isComboInputSpecV1(inputSpec)) {
widget.options.values = inputSpec[0]
}
}
}
}
}

View File

@@ -10,6 +10,7 @@ import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { api } from './api'
import type { ComfyApp } from './app'
import { app } from './app'
function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
@@ -36,11 +37,6 @@ export class ChangeTracker {
ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any>
static app?: ComfyApp
get app(): ComfyApp {
return ChangeTracker.app!
}
constructor(
/**
* The workflow that this change tracker is tracking
@@ -68,18 +64,18 @@ export class ChangeTracker {
store() {
this.ds = {
scale: this.app.canvas.ds.scale,
offset: [this.app.canvas.ds.offset[0], this.app.canvas.ds.offset[1]]
scale: app.canvas.ds.scale,
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
}
}
restore() {
if (this.ds) {
this.app.canvas.ds.scale = this.ds.scale
this.app.canvas.ds.offset = this.ds.offset
app.canvas.ds.scale = this.ds.scale
app.canvas.ds.offset = this.ds.offset
}
if (this.nodeOutputs) {
this.app.nodeOutputs = this.nodeOutputs
app.nodeOutputs = this.nodeOutputs
}
}
@@ -105,10 +101,8 @@ export class ChangeTracker {
}
checkState() {
if (!this.app.graph || this.changeCount) return
// @ts-expect-error zod type issue on ComfyWorkflowJSON. ComfyWorkflowJSON
// is stricter than LiteGraph's serialisation schema.
const currentState = clone(this.app.graph.serialize()) as ComfyWorkflowJSON
if (!app.graph || this.changeCount) return
const currentState = clone(app.graph.serialize()) as ComfyWorkflowJSON
if (!this.activeState) {
this.activeState = currentState
return
@@ -132,7 +126,7 @@ export class ChangeTracker {
target.push(this.activeState)
this.restoringState = true
try {
await this.app.loadGraphData(prevState, false, false, this.workflow, {
await app.loadGraphData(prevState, false, false, this.workflow, {
showMissingModelsDialog: false,
showMissingNodesDialog: false,
checkForRerouteMigration: false
@@ -189,13 +183,11 @@ export class ChangeTracker {
}
}
static init(app: ComfyApp) {
static init() {
const getCurrentChangeTracker = () =>
useWorkflowStore().activeWorkflow?.changeTracker
const checkState = () => getCurrentChangeTracker()?.checkState()
ChangeTracker.app = app
let keyIgnored = false
window.addEventListener(
'keydown',
@@ -237,7 +229,7 @@ export class ChangeTracker {
if (await changeTracker.undoRedo(e)) return
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(app, bindInputEl)) return
if (ChangeTracker.bindInput(bindInputEl)) return
logger.debug('checkState on keydown')
changeTracker.checkState()
})
@@ -339,7 +331,7 @@ export class ChangeTracker {
})
}
static bindInput(_app: ComfyApp, activeEl: Element | null): boolean {
static bindInput(activeEl: Element | null): boolean {
if (
!activeEl ||
activeEl.tagName === 'CANVAS' ||

View File

@@ -47,10 +47,13 @@ export interface DOMWidget<T extends HTMLElement, V extends object | string>
/**
* A DOM widget that wraps a Vue component as a litegraph widget.
*/
export interface ComponentWidget<V extends object | string>
extends BaseDOMWidget<V> {
export interface ComponentWidget<
V extends object | string,
P = Record<string, unknown>
> extends BaseDOMWidget<V> {
readonly component: Component
readonly inputSpec: InputSpec
readonly props?: P
}
export interface DOMWidgetOptions<V extends object | string>
@@ -217,18 +220,23 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
}
}
export class ComponentWidgetImpl<V extends object | string>
export class ComponentWidgetImpl<
V extends object | string,
P = Record<string, unknown>
>
extends BaseDOMWidgetImpl<V>
implements ComponentWidget<V>
implements ComponentWidget<V, P>
{
readonly component: Component
readonly inputSpec: InputSpec
readonly props?: P
constructor(obj: {
node: LGraphNode
name: string
component: Component
inputSpec: InputSpec
props?: P
options: DOMWidgetOptions<V>
}) {
super({
@@ -237,6 +245,7 @@ export class ComponentWidgetImpl<V extends object | string>
})
this.component = obj.component
this.inputSpec = obj.inputSpec
this.props = obj.props
}
override computeLayoutSize() {

257
src/services/README.md Normal file
View File

@@ -0,0 +1,257 @@
# Services
This directory contains the service layer for the ComfyUI frontend application. Services encapsulate application logic and functionality into organized, reusable modules.
## Table of Contents
- [Overview](#overview)
- [Service Architecture](#service-architecture)
- [Core Services](#core-services)
- [Service Development Guidelines](#service-development-guidelines)
- [Common Design Patterns](#common-design-patterns)
## Overview
Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
The term "business logic" in this context refers to the code that implements the core functionality and behavior of the application - the rules, processes, and operations that make ComfyUI work as expected, separate from the UI display code.
Services help organize related functionality into cohesive units, making the codebase more maintainable and testable. By centralizing related operations in services, the application achieves better separation of concerns, with UI components focusing on presentation and services handling functional operations.
## Service Architecture
The service layer in ComfyUI follows these architectural principles:
1. **Domain-driven**: Each service focuses on a specific domain of the application
2. **Stateless when possible**: Services generally avoid maintaining internal state
3. **Reusable**: Services can be used across multiple components
4. **Testable**: Services are designed for easy unit testing
5. **Isolated**: Services have clear boundaries and dependencies
While services can interact with both UI components and stores (centralized state), they primarily focus on implementing functionality rather than managing state. The following diagram illustrates how services fit into the application architecture:
```
┌─────────────────────────────────────────────────────────┐
│ UI Components │
└────────────────────────────┬────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Composables │
└────────────────────────────┬────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Services │
│ │
│ (Application Functionality) │
└────────────────────────────┬────────────────────────────┘
┌───────────┴───────────┐
▼ ▼
┌───────────────────────────┐ ┌─────────────────────────┐
│ Stores │ │ External APIs │
│ (Centralized State) │ │ │
└───────────────────────────┘ └─────────────────────────┘
```
## Core Services
The core services include:
| Service | Description |
|---------|-------------|
| algoliaSearchService.ts | Implements search functionality using Algolia |
| autoQueueService.ts | Manages automatic queue execution |
| colorPaletteService.ts | Handles color palette management and customization |
| comfyManagerService.ts | Manages ComfyUI application packages and updates |
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions |
| dialogService.ts | Provides dialog and modal management |
| extensionService.ts | Manages extension registration and lifecycle |
| keybindingService.ts | Handles keyboard shortcuts and keybindings |
| litegraphService.ts | Provides utilities for working with the LiteGraph library |
| load3dService.ts | Manages 3D model loading and visualization |
| nodeSearchService.ts | Implements node search functionality |
| workflowService.ts | Handles workflow operations (save, load, execute) |
## Service Development Guidelines
In ComfyUI, services can be implemented using two approaches:
### 1. Class-based Services
For complex services with state management and multiple methods, class-based services are used:
```typescript
export class NodeSearchService {
// Service state
private readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
private readonly filters: Record<string, FuseFilter<ComfyNodeDefImpl, string>>
constructor(data: ComfyNodeDefImpl[]) {
// Initialize state
this.nodeFuseSearch = new FuseSearch(data, { /* options */ })
// Setup filters
this.filters = {
inputType: new FuseFilter<ComfyNodeDefImpl, string>(/* options */),
category: new FuseFilter<ComfyNodeDefImpl, string>(/* options */)
}
}
public searchNode(query: string, filters: FuseFilterWithValue[] = []): ComfyNodeDefImpl[] {
// Implementation
return results
}
}
```
### 2. Composable-style Services
For simpler services or those that need to integrate with Vue's reactivity system, we prefer using composable-style services:
```typescript
export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
// State (reactive if needed)
const data = ref(initialData)
// Search functionality
function searchNodes(query: string) {
// Implementation
return results
}
// Additional methods
function refreshData(newData: ComfyNodeDefImpl[]) {
data.value = newData
}
// Return public API
return {
searchNodes,
refreshData
}
}
```
When deciding between these approaches, consider:
1. **Stateful vs. Stateless**: For stateful services, classes often provide clearer encapsulation
2. **Reactivity needs**: If the service needs to be reactive, composable-style services integrate better with Vue's reactivity system
3. **Complexity**: For complex services with many methods and internal state, classes can provide better organization
4. **Testing**: Both approaches can be tested effectively, but composables may be simpler to test with Vue Test Utils
### Service Template
Here's a template for creating a new composable-style service:
```typescript
/**
* Service for managing [domain/functionality]
*/
export function useExampleService() {
// Private state/functionality
const cache = new Map()
/**
* Description of what this method does
* @param param1 Description of parameter
* @returns Description of return value
*/
async function performOperation(param1: string) {
try {
// Implementation
return result
} catch (error) {
// Error handling
console.error(`Operation failed: ${error.message}`)
throw error
}
}
// Return public API
return {
performOperation
}
}
```
## Common Design Patterns
Services in ComfyUI frequently use the following design patterns:
### Caching and Request Deduplication
```typescript
export function useCachedService() {
const cache = new Map()
const pendingRequests = new Map()
async function fetchData(key: string) {
// Check cache first
if (cache.has(key)) return cache.get(key)
// Check if request is already in progress
if (pendingRequests.has(key)) {
return pendingRequests.get(key)
}
// Perform new request
const requestPromise = fetch(`/api/${key}`)
.then(response => response.json())
.then(data => {
cache.set(key, data)
pendingRequests.delete(key)
return data
})
pendingRequests.set(key, requestPromise)
return requestPromise
}
return { fetchData }
}
```
### Factory Pattern
```typescript
export function useNodeFactory() {
function createNode(type: string, config: Record<string, any>) {
// Create node based on type and configuration
switch (type) {
case 'basic':
return { /* basic node implementation */ }
case 'complex':
return { /* complex node implementation */ }
default:
throw new Error(`Unknown node type: ${type}`)
}
}
return { createNode }
}
```
### Facade Pattern
```typescript
export function useWorkflowService(
apiService,
graphService,
storageService
) {
// Provides a simple interface to complex subsystems
async function saveWorkflow(name: string) {
const graphData = graphService.serializeGraph()
const storagePath = await storageService.getPath(name)
return apiService.saveData(storagePath, graphData)
}
return { saveWorkflow }
}
```
For more detailed information about the service layer pattern and its applications, refer to:
- [Service Layer Pattern](https://en.wikipedia.org/wiki/Service_layer_pattern)
- [Service-Orientation](https://en.wikipedia.org/wiki/Service-orientation)

View File

@@ -1,12 +1,18 @@
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,
@@ -15,6 +21,10 @@ type SafeNestedProperty<
> = 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']
@@ -91,8 +101,33 @@ type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
restrictSearchableAttributes: SearchAttribute[]
}
export const useAlgoliaSearchService = () => {
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
@@ -141,34 +176,39 @@ export const useAlgoliaSearchService = () => {
const searchPacks = async (
query: string,
params: SearchNodePacksParams
): Promise<{
nodePacks: Hit<AlgoliaNodePack>[]
querySuggestions: Hit<NodesIndexSuggestion>[]
}> => {
): 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: [
{
query,
indexName: 'nodes_index',
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
...rest,
hitsPerPage: pageSize,
page: pageNumber
},
{
indexName: 'nodes_index_query_suggestions',
query
}
],
requests,
strategy: 'none'
})
const [nodePacks, querySuggestions] = results as [
const [nodePacks, querySuggestions = { hits: [] }] = results as [
SearchResponse<AlgoliaNodePack>,
SearchResponse<NodesIndexSuggestion>
]
@@ -179,8 +219,27 @@ export const useAlgoliaSearchService = () => {
}
}
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,
toRegistryPack
searchPacksCached,
toRegistryPack,
clearSearchPacksCache
}
}

View File

@@ -1,4 +1,3 @@
import ApiNodesNewsContent from '@/components/dialog/content/ApiNodesNewsContent.vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
@@ -380,32 +379,6 @@ export const useDialogService = () => {
})
}
/**
* Shows a dialog for the API nodes news.
* TODO: Remove the news dialog on next major feature release.
*/
function showApiNodesNewsDialog() {
if (localStorage.getItem('api-nodes-news-seen') === 'true') {
return
}
return dialogStore.showDialog({
key: 'api-nodes-news',
component: ApiNodesNewsContent,
props: {
dismissableMask: true,
onClose: () => {
dialogStore.closeDialog({ key: 'api-nodes-news' })
localStorage.setItem('api-nodes-news-seen', 'true')
}
},
dialogComponentProps: {
closable: false,
position: 'bottomright'
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -421,7 +394,6 @@ export const useDialogService = () => {
showSignInDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showApiNodesNewsDialog,
prompt,
confirm
}

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