Compare commits

..

2 Commits

Author SHA1 Message Date
Chenlei Hu
702458acbd Refactor + error handling 2025-05-07 14:52:37 -04:00
Chenlei Hu
fd01f13fbf Update link_fixer from rgthree 2025-05-07 14:44:28 -04:00
186 changed files with 1945 additions and 25569 deletions

View File

@@ -25,7 +25,3 @@ 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

View File

@@ -1,72 +0,0 @@
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: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
ref: '9d2421fd3208a310e4d0f71fca2ea0c985759c33'
- uses: actions/setup-node@v4
with:

135
README.md
View File

@@ -526,38 +526,6 @@ 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/)
@@ -579,6 +547,7 @@ 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
@@ -606,7 +575,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.).
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.
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.
### Unit Test
@@ -637,103 +606,3 @@ 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

@@ -33,11 +33,5 @@
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -130,11 +130,6 @@
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"extra": {},
"version": 0.4
}

View File

@@ -42,12 +42,12 @@
"config": {},
"extra": {
"ds": {
"scale": 1,
"scale": 2.1600300525920346,
"offset": [
0,
0
63.071794466403446,
75.18055335968394
]
}
},
"version": 0.4
}
}

View File

@@ -499,11 +499,6 @@
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"extra": {},
"version": 0.4
}
}

View File

@@ -1,85 +0,0 @@
{
"id": "1a95532f-c8aa-4c9d-a7f6-f928ba2d4862",
"revision": 0,
"last_node_id": 4,
"last_link_id": 3,
"nodes": [
{
"id": 4,
"type": "PreviewAny",
"pos": [946.2566528320312, 598.4373168945312],
"size": [140, 76],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 3
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": []
},
{
"id": 1,
"type": "PreviewAny",
"pos": [951.0236206054688, 421.3861083984375],
"size": [140, 76],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": []
},
{
"id": 3,
"type": "PrimitiveString",
"pos": [575.1760864257812, 504.5214538574219],
"size": [270, 58],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [2, 3]
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": ["foo"]
}
],
"links": [
[2, 3, 0, 1, 0, "*"],
[3, 3, 0, 4, 0, "*"]
],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.19.1",
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -160,4 +160,4 @@
}
},
"version": 0.4
}
}

View File

@@ -43,10 +43,6 @@
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"groupNodes": {
"group_node": {
"nodes": [
@@ -405,4 +401,4 @@
}
},
"version": 0.4
}
}

View File

@@ -110,10 +110,6 @@
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"groupNodes": {
"hello": {
"nodes": [
@@ -253,4 +249,4 @@
}
},
"version": 0.4
}
}

View File

@@ -44,11 +44,6 @@
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"extra": {},
"version": 0.4
}

View File

@@ -61,11 +61,6 @@
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"extra": {},
"version": 0.4
}
}

View File

@@ -1,36 +0,0 @@
{
"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

@@ -50,11 +50,6 @@
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"extra": {},
"version": 0.4
}
}

View File

@@ -38,11 +38,7 @@
"groups": [],
"config": {},
"extra": {
"groupNodes": {},
"ds": {
"offset": [0, 0],
"scale": 1
}
"groupNodes": {}
},
"version": 0.4
}
}

View File

@@ -54,11 +54,6 @@
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"extra": {},
"version": 0.4
}

View File

@@ -92,14 +92,10 @@
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": false,
"VHS_KeepIntermediate": false
},
"version": 0.4
}
}

View File

@@ -84,10 +84,6 @@
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"reroutes": [
{
"id": 1,

View File

@@ -106,7 +106,10 @@
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
"offset": {
"0": 0,
"1": 0
}
}
},
"version": 0.4

View File

@@ -368,10 +368,10 @@
"ds": {
"scale": 1,
"offset": [
0,
0
149.9747408641311,
383.8593224280729
]
}
},
"version": 0.4
}
}

View File

@@ -31,11 +31,5 @@
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}
}

View File

@@ -8,4 +8,4 @@
"title": "Load Animated Image"
}
}
}
}

View File

@@ -27,11 +27,6 @@
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"extra": {},
"version": 0.4
}

View File

@@ -41,11 +41,6 @@
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"extra": {},
"version": 0.4
}
}

View File

@@ -54,11 +54,7 @@
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0",
"ds": {
"offset": [0, 0],
"scale": 1
}
"frontendVersion": "1.17.0"
},
"version": 0.4
}

View File

@@ -133,9 +133,6 @@ export class ComfyPage {
// Inputs
public readonly workflowUploadInput: Locator
// Toasts
public readonly visibleToasts: Locator
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
@@ -162,8 +159,6 @@ 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)
@@ -275,6 +270,7 @@ export class ComfyPage {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
localStorage.setItem('api-nodes-news-seen', 'true')
}, this.id)
}
await this.goto()
@@ -401,30 +397,6 @@ 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()
@@ -441,20 +413,7 @@ export class ComfyPage {
}
async getVisibleToastCount() {
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)
return await this.page.locator('.p-toast-message:visible').count()
}
async clickTextEncodeNode1() {

View File

@@ -18,27 +18,3 @@ test.describe('Execution', () => {
)
})
})
test.describe('Execute to selected output nodes', () => {
test('Execute to selected output nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('execution/partial_execution')
const input = await comfyPage.getNodeRefById(3)
const output1 = await comfyPage.getNodeRefById(1)
const output2 = await comfyPage.getNodeRefById(4)
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
await output1.click('title')
await comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
// @note: Wait for the execution to finish. We might want to move to a more
// reliable way to wait for the execution to finish. Workflow in this test
// is simple enough that this is fine for now.
await comfyPage.page.waitForTimeout(200)
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
})
})

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -666,12 +666,6 @@ test.describe('Load workflow', () => {
expect(activeWorkflowName).toEqual(workflowPathB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableWorkflowViewRestore', false)
await comfyPage.loadWorkflow('single_ksampler')
await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler_fit.png')
})
})
test.describe('Load duplicate workflow', () => {
@@ -689,42 +683,3 @@ 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.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -53,26 +53,6 @@ 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', () => {

1717
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.20.2",
"version": "1.19.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -63,8 +63,6 @@
"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",
@@ -74,7 +72,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.11",
"@comfyorg/litegraph": "^0.15.3",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

View File

@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
// Mock the execution store
const executionStore = reactive({
@@ -30,8 +31,11 @@ vi.mock('@/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStore
}))
describe('useBrowserTabTitle', () => {
describe('BrowserTabTitle.vue', () => {
let wrapper: ReturnType<typeof mount> | null
beforeEach(() => {
wrapper = null
// reset execution store
executionStore.isIdle = true
executionStore.executionProgress = 0
@@ -46,8 +50,12 @@ describe('useBrowserTabTitle', () => {
document.title = ''
})
afterEach(() => {
wrapper?.unmount()
})
it('sets default title when idle and no workflow', () => {
useBrowserTabTitle()
wrapper = mount(BrowserTabTitle)
expect(document.title).toBe('ComfyUI')
})
@@ -58,7 +66,7 @@ describe('useBrowserTabTitle', () => {
isModified: false,
isPersisted: true
}
useBrowserTabTitle()
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
})
@@ -70,21 +78,19 @@ describe('useBrowserTabTitle', () => {
isModified: true,
isPersisted: true
}
useBrowserTabTitle()
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('*myFlow - ComfyUI')
})
// Fails when run together with other tests. Suspect to be caused by leaked
// state from previous tests.
it.skip('disables workflow title when menu disabled', async () => {
it('disables workflow title when menu disabled', async () => {
;(settingStore.get as any).mockReturnValue('Disabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,
isPersisted: true
}
useBrowserTabTitle()
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('ComfyUI')
})
@@ -92,7 +98,7 @@ describe('useBrowserTabTitle', () => {
it('shows execution progress when not idle without workflow', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.3
useBrowserTabTitle()
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('[30%]ComfyUI')
})
@@ -102,7 +108,7 @@ describe('useBrowserTabTitle', () => {
executionStore.executionProgress = 0.4
executionStore.executingNodeProgress = 0.5
executionStore.executingNode = { type: 'Foo' }
useBrowserTabTitle()
wrapper = mount(BrowserTabTitle)
await nextTick()
expect(document.title).toBe('[40%][50%] Foo')
})

View File

@@ -0,0 +1,58 @@
<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,6 +63,15 @@ 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
@@ -73,25 +82,9 @@ const setInitialPosition = () => {
return
}
// 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()
}
x.value = (screenWidth - menuWidth) / 2
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
captureLastDragState()
}
}
onMounted(setInitialPosition)

View File

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

View File

@@ -70,12 +70,11 @@ 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: hasMoreToRender && remainingCol <= cols.value * bufferRows
isNearEnd: remainingCol <= cols.value * bufferRows
}
})
const renderedItems = computed(() =>

View File

@@ -0,0 +1,74 @@
<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 authActions = useFirebaseAuthActions()
const authService = useFirebaseAuthService()
// 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 authActions.fetchBalance()
void authService.fetchBalance()
}
})
</script>

View File

@@ -1,141 +1,95 @@
<template>
<div class="w-96 p-2 overflow-x-hidden">
<ApiKeyForm
v-if="showApiKeyForm"
@back="showApiKeyForm = false"
@success="onSuccess"
/>
<template v-else>
<!-- Header -->
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{
isSignIn
? t('auth.login.newUser')
: t('auth.signup.alreadyHaveAccount')
}}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
@click="toggleState"
>{{
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
}}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
<template v-else>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else @submit="signUpWithEmail" />
</template>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="showApiKeyForm = true"
>
<img
src="/assets/images/comfy-logo-mono.svg"
class="w-5 h-5 mr-2"
alt="Comfy"
/>
{{ t('auth.login.useApiKey') }}
</Button>
<small class="text-muted text-center">
{{ t('auth.apiKey.helpText') }}
<a
: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 -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.privacyLink') }} </a
>.
{{ t('auth.login.questionsContactPrefix') }}
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
hello@comfy.org</a
>.
<div class="w-96 p-2">
<!-- Header -->
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{
isSignIn
? t('auth.login.newUser')
: t('auth.signup.alreadyHaveAccount')
}}</span>
<span class="ml-1 cursor-pointer text-blue-500" @click="toggleState">{{
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
}}</span>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
<template v-else>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else @submit="signUpWithEmail" />
</template>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
</div>
<!-- Terms & Contact -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.privacyLink') }} </a
>.
{{ t('auth.login.questionsContactPrefix') }}
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
hello@comfy.org</a
>.
</p>
</div>
</template>
@@ -143,15 +97,13 @@
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, 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'
import SignInForm from './signin/SignInForm.vue'
import SignUpForm from './signin/SignUpForm.vue'
@@ -160,36 +112,33 @@ const { onSuccess } = defineProps<{
}>()
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const authService = useFirebaseAuthService()
const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const toggleState = () => {
isSignIn.value = !isSignIn.value
showApiKeyForm.value = false
}
const signInWithGoogle = async () => {
if (await authActions.signInWithGoogle()) {
if (await authService.signInWithGoogle()) {
onSuccess()
}
}
const signInWithGithub = async () => {
if (await authActions.signInWithGithub()) {
if (await authService.signInWithGithub()) {
onSuccess()
}
}
const signInWithEmail = async (values: SignInData) => {
if (await authActions.signInWithEmail(values.email, values.password)) {
if (await authService.signInWithEmail(values.email, values.password)) {
onSuccess()
}
}
const signUpWithEmail = async (values: SignUpData) => {
if (await authActions.signUpWithEmail(values.email, values.password)) {
if (await authService.signUpWithEmail(values.email, values.password)) {
onSuccess()
}
}
@@ -198,8 +147,4 @@ 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 { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
@@ -65,9 +65,9 @@ const {
preselectedAmountOption?: number
}>()
const authActions = useFirebaseAuthActions()
const authService = useFirebaseAuthService()
const handleSeeDetails = async () => {
await authActions.accessBillingPortal()
await authService.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 authActions = useFirebaseAuthActions()
const authService = useFirebaseAuthService()
const loading = ref(false)
const { onSuccess } = defineProps<{
@@ -37,7 +37,7 @@ const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
loading.value = true
try {
await authActions.updatePassword(event.values.password)
await authService.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 { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
const authActions = useFirebaseAuthActions()
const authService = useFirebaseAuthService()
const {
amount,
@@ -61,7 +61,7 @@ const loading = ref(false)
const handleBuyNow = async () => {
loading.value = true
await authActions.purchaseCredits(editable ? customAmount.value : amount)
await authService.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 authActions.fetchBalance()
void authService.fetchBalance()
}
})
</script>

View File

@@ -55,7 +55,6 @@
/>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="3"
:grid-style="GRID_STYLE"
@@ -93,7 +92,7 @@
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -200,10 +199,6 @@ 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
)
@@ -424,17 +419,6 @@ 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,7 +20,6 @@
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>

View File

@@ -36,7 +36,7 @@
text
size="small"
severity="secondary"
@click="() => authActions.fetchBalance()"
@click="() => authService.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 authActions = useFirebaseAuthActions()
const authService = useFirebaseAuthService()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
@@ -142,7 +142,7 @@ const handlePurchaseCreditsClick = () => {
}
const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
await authService.accessBillingPortal()
}
const handleMessageSupport = () => {

View File

@@ -1,8 +1,6 @@
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'
@@ -21,16 +19,7 @@ describe('SettingItem', () => {
const mountComponent = (props: any, options = {}): any => {
return mount(SettingItem, {
global: {
plugins: [PrimeVue, i18n, createPinia()],
components: {
Tag
},
directives: {
tooltip: Tooltip
},
stubs: {
'i-material-symbols:experiment-outline': true
}
plugins: [PrimeVue, i18n, createPinia()]
},
props,
...options

View File

@@ -4,11 +4,10 @@
<h2 class="text-2xl font-bold mb-2">{{ $t('userSettings.title') }}</h2>
<Divider class="mb-3" />
<!-- Normal User Panel -->
<div v-if="isLoggedIn" class="flex flex-col gap-2">
<div v-if="user" class="flex flex-col gap-2">
<UserAvatar
v-if="userPhotoUrl"
:photo-url="userPhotoUrl"
v-if="user.photoURL"
:photo-url="user.photoURL"
shape="circle"
size="large"
/>
@@ -18,7 +17,7 @@
{{ $t('userSettings.name') }}
</h3>
<div class="text-muted">
{{ userDisplayName || $t('userSettings.notSet') }}
{{ user.displayName || $t('userSettings.notSet') }}
</div>
</div>
@@ -26,9 +25,9 @@
<h3 class="font-medium">
{{ $t('userSettings.email') }}
</h3>
<span class="text-muted">
{{ userEmail }}
</span>
<a :href="'mailto:' + user.email" class="hover:underline">
{{ user.email }}
</a>
</div>
<div class="flex flex-col gap-0.5">
@@ -91,22 +90,51 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const dialogService = useDialogService()
const {
loading,
isLoggedIn,
isEmailProvider,
userDisplayName,
userEmail,
userPhotoUrl,
providerName,
providerIcon,
handleSignOut,
handleSignIn
} = useCurrentUser()
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const user = computed(() => authStore.currentUser)
const loading = computed(() => authStore.loading)
const providerName = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'Google'
}
if (providerId?.includes('github')) {
return 'GitHub'
}
return providerId
})
const providerIcon = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'pi pi-google'
}
if (providerId?.includes('github')) {
return 'pi pi-github'
}
return 'pi pi-user'
})
const isEmailProvider = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
return providerId === 'password'
})
const handleSignOut = async () => {
await commandStore.execute('Comfy.User.SignOut')
}
const handleSignIn = async () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}
</script>

View File

@@ -1,117 +0,0 @@
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
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()
const mockLoading = vi.fn(() => false)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
loading: mockLoading()
}))
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: vi.fn(() => ({
storeApiKey: mockStoreApiKey
}))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
auth: {
apiKey: {
title: 'API Key',
label: 'API Key',
placeholder: 'Enter your API Key',
error: 'Invalid API Key',
helpText: 'Need an API key?',
generateKey: 'Get one here',
whitelistInfo: 'About non-whitelisted sites',
description: 'Use your Comfy API key to enable API Nodes'
}
},
g: {
back: 'Back',
save: 'Save',
learnMore: 'Learn more'
}
}
}
})
describe('ApiKeyForm', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
vi.clearAllMocks()
mockStoreApiKey.mockReset()
mockLoading.mockReset()
})
const mountComponent = (props: any = {}) => {
return mount(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: { Button, Form, InputText, Message }
},
props
})
}
it('renders correctly with all required elements', () => {
const wrapper = mountComponent()
expect(wrapper.find('h1').text()).toBe('API Key')
expect(wrapper.find('label').text()).toBe('API Key')
expect(wrapper.findComponent(InputText).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('emits back event when back button is clicked', async () => {
const wrapper = mountComponent()
await wrapper.findComponent(Button).trigger('click')
expect(wrapper.emitted('back')).toBeTruthy()
})
it('shows loading state when submitting', async () => {
mockLoading.mockReturnValue(true)
const wrapper = mountComponent()
const input = wrapper.findComponent(InputText)
await input.setValue(
'comfyui-123456789012345678901234567890123456789012345678901234567890123456789012'
)
await wrapper.find('form').trigger('submit')
const submitButton = wrapper
.findAllComponents(Button)
.find((btn) => btn.text() === 'Save')
expect(submitButton?.props('loading')).toBe(true)
})
it('displays help text and links correctly', () => {
const wrapper = mountComponent()
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
`${COMFY_PLATFORM_BASE_URL}/login`
)
})
})

View File

@@ -1,112 +0,0 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ t('auth.apiKey.title') }}
</h1>
<div class="flex flex-col gap-2">
<p class="text-base my-0 text-muted">
{{ t('auth.apiKey.description') }}
</p>
<a
href="https://docs.comfy.org/interface/user#logging-in-with-an-api-key"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('g.learnMore') }}
</a>
</div>
</div>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(apiKeySchema)"
@submit="onSubmit"
>
<Message v-if="$form.apiKey?.invalid" severity="error" class="mb-4">
{{ $form.apiKey.error.message }}
</Message>
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-api-key"
>
{{ t('auth.apiKey.label') }}
</label>
<div class="flex flex-col gap-2">
<InputText
pt:root:id="comfy-org-api-key"
pt:root:autocomplete="off"
class="h-10"
name="apiKey"
type="password"
:placeholder="t('auth.apiKey.placeholder')"
:invalid="$form.apiKey?.invalid"
/>
<small class="text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.generateKey') }}
</a>
<span class="mx-1"></span>
<a
href="https://docs.comfy.org/tutorials/api-nodes/overview#log-in-with-api-key-on-non-whitelisted-websites"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.whitelistInfo') }}
</a>
</small>
</div>
</div>
<div class="flex justify-between items-center mt-4">
<Button type="button" link @click="$emit('back')">
{{ t('g.back') }}
</Button>
<Button type="submit" :loading="loading" :disabled="loading">
{{ t('g.save') }}
</Button>
</div>
</Form>
</div>
</template>
<script setup lang="ts">
import { Form, FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
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'
const authStore = useFirebaseAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
const { t } = useI18n()
const emit = defineEmits<{
(e: 'back'): void
(e: 'success'): void
}>()
const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
await apiKeyStore.storeApiKey(event.values.apiKey)
emit('success')
}
}
</script>

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 firebaseAuthActions = useFirebaseAuthActions()
const firebaseAuthService = useFirebaseAuthService()
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 firebaseAuthActions.sendPasswordReset(email)
await firebaseAuthService.sendPasswordReset(email)
}
</script>

View File

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

View File

@@ -27,6 +27,7 @@
class="w-full h-full touch-none"
/>
<NodeBadge />
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover />
@@ -52,6 +53,7 @@ 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'
@@ -60,7 +62,6 @@ 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'
@@ -70,7 +71,7 @@ import { usePaste } from '@/composables/usePaste'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import { i18n } from '@/i18n'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -224,13 +225,39 @@ 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: t('toastMessages.nothingSelected'),
summary: 'No items selected',
life: 2000
})
},
@@ -253,7 +280,6 @@ const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
onMounted(async () => {
useGlobalLitegraph()
@@ -267,7 +293,7 @@ onMounted(async () => {
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
ChangeTracker.init(comfyApp)
await loadCustomNodesI18n()
try {
await settingStore.loadSettingValues()
@@ -292,8 +318,10 @@ onMounted(async () => {
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
window.app = comfyApp
window.graph = comfyApp.graph
// @ts-expect-error fixme ts strict error
window['app'] = comfyApp
// @ts-expect-error fixme ts strict error
window['graph'] = comfyApp.graph
comfyAppReady.value = true

View File

@@ -0,0 +1,114 @@
<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,10 +14,12 @@
<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()
@@ -26,8 +28,8 @@ const { style, updatePosition } = useAbsolutePosition()
const visible = ref(false)
const showBorder = ref(false)
const positionSelectionOverlay = () => {
const { selectedItems } = canvasStore.getCanvas()
const positionSelectionOverlay = (canvas: LGraphCanvas) => {
const selectedItems = canvas.selectedItems
showBorder.value = selectedItems.size > 1
if (!selectedItems.size) {
@@ -46,18 +48,26 @@ const positionSelectionOverlay = () => {
}
// Register listener on canvas creation.
whenever(
() => canvasStore.getCanvas().state.selectionChanged,
() => {
requestAnimationFrame(() => {
positionSelectionOverlay()
canvasStore.getCanvas().state.selectionChanged = false
})
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))
)
},
{ immediate: true }
)
canvasStore.getCanvas().ds.onChanged = positionSelectionOverlay
whenever(
() => canvasStore.getCanvas().ds.state,
() => positionSelectionOverlay(canvasStore.getCanvas()),
{ deep: true }
)
watch(
() => canvasStore.canvas?.state?.draggingItems,
@@ -67,10 +77,10 @@ watch(
// the correct position.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/2656
if (draggingItems === false) {
requestAnimationFrame(() => {
setTimeout(() => {
visible.value = true
positionSelectionOverlay()
})
positionSelectionOverlay(canvasStore.canvas as LGraphCanvas)
}, 100)
} 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,41 +6,94 @@
content: 'p-0 flex flex-row'
}"
>
<ExecuteButton />
<ColorPickerButton />
<BypassButton />
<PinButton />
<DeleteButton />
<RefreshButton />
<ExtensionCommandButton
<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
v-for="command in extensionToolboxCommands"
:key="command.id"
:command="command"
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)"
/>
</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 { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
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'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
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<ComfyCommandImpl[]>(() => {
const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
const commandIds = new Set<string>(
canvasStore.selectedItems
.map(
@@ -53,7 +106,7 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
)
return Array.from(commandIds)
.map((commandId) => commandStore.getCommand(commandId))
.filter((command): command is ComfyCommandImpl => command !== undefined)
.filter((command) => command !== undefined)
})
</script>

View File

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

View File

@@ -1,22 +0,0 @@
<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,72 +0,0 @@
<template>
<Button
v-show="canvasStore.nodeSelected"
v-tooltip.top="{
value: isDisabled
? t('selectionToolbox.executeButton.disabledTooltip')
: t('selectionToolbox.executeButton.tooltip'),
showDelay: 1000
}"
:severity="isDisabled ? 'secondary' : 'success'"
text
:disabled="isDisabled"
@mouseenter="() => handleMouseEnter()"
@mouseleave="() => handleMouseLeave()"
@click="handleClick"
>
<i-lucide:play />
</Button>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
const canvas = canvasStore.getCanvas()
const buttonHovered = ref(false)
const selectedOutputNodes = computed(
() =>
canvasStore.selectedItems.filter(
(item) => isLGraphNode(item) && item.constructor.nodeData?.output_node
) as LGraphNode[]
)
const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
function outputNodeStokeStyle(this: LGraphNode) {
if (
this.selected &&
this.constructor.nodeData?.output_node &&
buttonHovered.value
) {
return { color: 'orange', lineWidth: 2, padding: 10 }
}
}
const handleMouseEnter = () => {
buttonHovered.value = true
for (const node of selectedOutputNodes.value) {
node.strokeStyles['outputNode'] = outputNodeStokeStyle
}
canvas.setDirty(true)
}
const handleMouseLeave = () => {
buttonHovered.value = false
canvas.setDirty(true)
}
const handleClick = async () => {
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
}
</script>

View File

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

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

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

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

View File

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

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

@@ -1,22 +0,0 @@
<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,9 +52,6 @@ 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: () =>
@@ -78,7 +75,7 @@ const eventConfig = {
watchEffect(async () => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
const rawLoad3d = toRaw(load3d.value)
rawLoad3d.setBackgroundColor(props.backgroundColor)
rawLoad3d.toggleGrid(props.showGrid)
@@ -87,25 +84,15 @@ 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) as Load3d
const rawLoad3d = toRaw(load3d.value)
rawLoad3d.setMaterialMode(newValue)
}
@@ -115,9 +102,10 @@ watch(
watch(
() => props.edgeThreshold,
(newValue) => {
if (load3d.value && newValue) {
const rawLoad3d = toRaw(load3d.value) as Load3d
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value)
// @ts-expect-error fixme ts strict error
rawLoad3d.setEdgeThreshold(newValue)
}
}

View File

@@ -75,7 +75,7 @@
</template>
<script setup lang="ts">
import { LGraphNode } from '@comfyorg/litegraph'
import { IWidget, LGraphNode } from '@comfyorg/litegraph'
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
@@ -101,13 +101,13 @@ const emit = defineEmits<{
const resizeNodeMatchOutput = () => {
console.log('resizeNodeMatchOutput')
const outputWidth = node.widgets?.find((w) => w.name === 'width')
const outputHeight = node.widgets?.find((w) => w.name === 'height')
const outputWidth = node.widgets?.find((w: IWidget) => w.name === 'width')
const outputHeight = node.widgets?.find((w: IWidget) => w.name === 'height')
if (outputWidth && outputHeight && outputHeight.value && outputWidth.value) {
const [oldWidth, oldHeight] = node.size
const scene = node.widgets?.find((w) => w.name === 'image')
const scene = node.widgets?.find((w: IWidget) => w.name === 'image')
const sceneHeight = scene?.computedHeight

View File

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

View File

@@ -2,7 +2,7 @@
<template>
<div>
<Button
v-if="isLoggedIn"
v-if="isAuthenticated"
class="user-profile-button p-1"
severity="secondary"
text
@@ -30,14 +30,15 @@ import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import CurrentUserPopover from './CurrentUserPopover.vue'
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
const authStore = useFirebaseAuthStore()
const popover = ref<InstanceType<typeof Popover> | null>(null)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
() => authStore.currentUser?.photoURL ?? undefined
)
</script>

View File

@@ -6,19 +6,19 @@
<div class="flex flex-col items-center">
<UserAvatar
class="mb-3"
:photo-url="userPhotoUrl"
:photo-url="user?.photoURL"
:pt:icon:class="{
'!text-2xl': !userPhotoUrl
'!text-2xl': !user?.photoURL
}"
size="large"
/>
<!-- User Details -->
<h3 class="text-lg font-semibold truncate my-0 mb-1">
{{ userDisplayName || $t('g.user') }}
{{ user?.displayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="text-sm text-muted truncate my-0">
{{ userEmail }}
<p v-if="user?.email" class="text-sm text-muted truncate my-0">
{{ user.email }}
</p>
</div>
</div>
@@ -37,18 +37,6 @@
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('credits.apiPricing')"
icon="pi pi-external-link"
text
fluid
severity="secondary"
@click="handleOpenApiPricing"
/>
<Divider class="my-2" />
<div class="w-full flex flex-col gap-2 p-2">
<div class="text-muted text-sm">
{{ $t('credits.yourCreditBalance') }}
@@ -64,18 +52,20 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { onMounted } from 'vue'
import { computed, 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'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const authService = useFirebaseAuthService()
const dialogService = useDialogService()
const user = computed(() => authStore.currentUser)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
}
@@ -84,11 +74,7 @@ const handleTopUp = () => {
dialogService.showTopUpCreditsDialog()
}
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
}
onMounted(() => {
void authActions.fetchBalance()
void authService.fetchBalance()
})
</script>

View File

@@ -1,310 +0,0 @@
# 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,101 +0,0 @@
import { computed } from 'vue'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
export const useCurrentUser = () => {
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const apiKeyStore = useApiKeyAuthStore()
const firebaseUser = computed(() => authStore.currentUser)
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
const isLoggedIn = computed(
() => !!isApiKeyLogin.value || firebaseUser.value !== null
)
const userDisplayName = computed(() => {
if (isApiKeyLogin.value) {
return apiKeyStore.currentUser?.name
}
return firebaseUser.value?.displayName
})
const userEmail = computed(() => {
if (isApiKeyLogin.value) {
return apiKeyStore.currentUser?.email
}
return firebaseUser.value?.email
})
const providerName = computed(() => {
if (isApiKeyLogin.value) {
return 'Comfy API Key'
}
const providerId = firebaseUser.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'Google'
}
if (providerId?.includes('github')) {
return 'GitHub'
}
return providerId
})
const providerIcon = computed(() => {
if (isApiKeyLogin.value) {
return 'pi pi-key'
}
const providerId = firebaseUser.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'pi pi-google'
}
if (providerId?.includes('github')) {
return 'pi pi-github'
}
return 'pi pi-user'
})
const isEmailProvider = computed(() => {
if (isApiKeyLogin.value) {
return false
}
const providerId = firebaseUser.value?.providerData[0]?.providerId
return providerId === 'password'
})
const userPhotoUrl = computed(() => {
if (isApiKeyLogin.value) return null
return firebaseUser.value?.photoURL
})
const handleSignOut = async () => {
if (isApiKeyLogin.value) {
await apiKeyStore.clearStoredApiKey()
} else {
await commandStore.execute('Comfy.User.SignOut')
}
}
const handleSignIn = async () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}
return {
loading: authStore.loading,
isLoggedIn,
isApiKeyLogin,
isEmailProvider,
userDisplayName,
userEmail,
userPhotoUrl,
providerName,
providerIcon,
handleSignOut,
handleSignIn
}
}

View File

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

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

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

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

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

@@ -7,14 +7,13 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import type { SettingParams } from '@/types/settingTypes'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useCurrentUser } from '../auth/useCurrentUser'
interface SettingPanelItem {
node: SettingTreeNode
component: Component
@@ -30,7 +29,7 @@ export function useSettingUI(
| 'credits'
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
const firebaseAuthStore = useFirebaseAuthStore()
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
@@ -166,7 +165,7 @@ export function useSettingUI(
label: 'Account',
children: [
userPanel.node,
...(isLoggedIn.value ? [creditsPanel.node] : [])
...(firebaseAuthStore.isAuthenticated ? [creditsPanel.node] : [])
].map(translateCategory)
},
// Normal settings stored in the settingStore

View File

@@ -1,53 +0,0 @@
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)
}

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