Compare commits
44 Commits
v1.19.8
...
rh-algolia
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a93f620e | ||
|
|
6d87f2b2ff | ||
|
|
20911aa892 | ||
|
|
3a6018589e | ||
|
|
4c92a7142e | ||
|
|
293993e7de | ||
|
|
a7ee3fae05 | ||
|
|
22dc84324e | ||
|
|
e76e9ec61a | ||
|
|
94fde504d0 | ||
|
|
e3ecf90bb3 | ||
|
|
a131f36cf3 | ||
|
|
4cad1a9567 | ||
|
|
47a6c6d595 | ||
|
|
068279ec34 | ||
|
|
2885ebf5e0 | ||
|
|
d4e76ddc45 | ||
|
|
9a5b80a279 | ||
|
|
985dab7e1c | ||
|
|
7f2b8a5321 | ||
|
|
59ce169ec9 | ||
|
|
4294b2c13b | ||
|
|
242c7e2885 | ||
|
|
c1442ec755 | ||
|
|
ebd9c96a28 | ||
|
|
e6d649b596 | ||
|
|
b037ba84e3 | ||
|
|
7c5c47c105 | ||
|
|
b152f67d95 | ||
|
|
be84d81c32 | ||
|
|
a474a094f3 | ||
|
|
bc360eef15 | ||
|
|
a52cc0ebe9 | ||
|
|
b3c6513e7a | ||
|
|
a9bdc70e28 | ||
|
|
58906fa821 | ||
|
|
a17fb04f83 | ||
|
|
5c0ad994d8 | ||
|
|
31be0a04f0 | ||
|
|
d9ab4270d1 | ||
|
|
36bd1f74ca | ||
|
|
7144ec54aa | ||
|
|
b2f144c27b | ||
|
|
014c0022c1 |
@@ -25,3 +25,7 @@ ENABLE_MINIFY=true
|
||||
# templates are served via the normal method from the server's python site
|
||||
# packages.
|
||||
DISABLE_TEMPLATES_PROXY=false
|
||||
|
||||
# If playwright tests are being run via vite dev server, Vue plugins will
|
||||
# invalidate screenshots. When `true`, vite plugins will not be loaded.
|
||||
DISABLE_VUE_PLUGINS=false
|
||||
|
||||
72
.github/workflows/dev-release.yaml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Create Dev PyPI Package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
devVersion:
|
||||
description: 'Dev version'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.current_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: Build project
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
|
||||
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm run zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-files
|
||||
path: |
|
||||
dist/
|
||||
dist.zip
|
||||
|
||||
publish_pypi:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-files
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
- name: Setup pypi package
|
||||
run: |
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
148
README.md
@@ -526,14 +526,46 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
|
||||
## Development
|
||||
|
||||
### Tech Stack
|
||||
### Prerequisites & Technology Stack
|
||||
|
||||
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Pinia](https://pinia.vuejs.org/) for state management
|
||||
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
|
||||
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
|
||||
- [zod](https://zod.dev/) for schema validation
|
||||
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
|
||||
- **Required Software**:
|
||||
- Node.js (v16 or later) and npm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance
|
||||
|
||||
- **Tech Stack**:
|
||||
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Pinia](https://pinia.vuejs.org/) for state management
|
||||
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
|
||||
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
|
||||
- [zod](https://zod.dev/) for schema validation
|
||||
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Git pre-commit hooks
|
||||
|
||||
@@ -575,7 +607,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.
|
||||
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
|
||||
### Unit Test
|
||||
|
||||
@@ -606,3 +638,103 @@ This will replace the litegraph package in this repo with the local litegraph re
|
||||
### i18n
|
||||
|
||||
See [locales/README.md](src/locales/README.md) for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
> **Note**: For comprehensive troubleshooting and how-to guides, please refer to our [official documentation](https://docs.comfy.org/). This section covers only the most common issues related to frontend development.
|
||||
|
||||
> **Desktop Users**: For issues specific to the desktop application, please refer to the [ComfyUI desktop repository](https://github.com/Comfy-Org/desktop).
|
||||
|
||||
### Debugging Custom Node (Extension) Issues
|
||||
|
||||
If you're experiencing crashes, errors, or unexpected behavior with ComfyUI, it's often caused by custom nodes (extensions). Follow these steps to identify and resolve the issues:
|
||||
|
||||
#### Step 1: Verify if custom nodes are causing the problem
|
||||
|
||||
Run ComfyUI with the `--disable-all-custom-nodes` flag:
|
||||
|
||||
```bash
|
||||
python main.py --disable-all-custom-nodes
|
||||
```
|
||||
|
||||
If the issue disappears, a custom node is the culprit. Proceed to the next step.
|
||||
|
||||
#### Step 2: Identify the problematic custom node using binary search
|
||||
|
||||
Rather than disabling nodes one by one, use this more efficient approach:
|
||||
|
||||
1. Temporarily move half of your custom nodes out of the `custom_nodes` directory
|
||||
```bash
|
||||
# Create a temporary directory
|
||||
# Linux/Mac
|
||||
mkdir ~/custom_nodes_disabled
|
||||
|
||||
# Windows
|
||||
mkdir %USERPROFILE%\custom_nodes_disabled
|
||||
|
||||
# Move half of your custom nodes (assuming you have node1 through node8)
|
||||
# Linux/Mac
|
||||
mv custom_nodes/node1 custom_nodes/node2 custom_nodes/node3 custom_nodes/node4 ~/custom_nodes_disabled/
|
||||
|
||||
# Windows
|
||||
move custom_nodes\node1 custom_nodes\node2 custom_nodes\node3 custom_nodes\node4 %USERPROFILE%\custom_nodes_disabled\
|
||||
```
|
||||
|
||||
2. Run ComfyUI again
|
||||
- If the issue persists: The problem is in nodes 5-8 (the remaining half)
|
||||
- If the issue disappears: The problem is in nodes 1-4 (the moved half)
|
||||
|
||||
3. Let's assume the issue disappeared, so the problem is in nodes 1-4. Move half of these for the next test:
|
||||
```bash
|
||||
# Move nodes 3-4 back to custom_nodes
|
||||
# Linux/Mac
|
||||
mv ~/custom_nodes_disabled/node3 ~/custom_nodes_disabled/node4 custom_nodes/
|
||||
|
||||
# Windows
|
||||
move %USERPROFILE%\custom_nodes_disabled\node3 %USERPROFILE%\custom_nodes_disabled\node4 custom_nodes\
|
||||
```
|
||||
|
||||
4. Run ComfyUI again
|
||||
- If the issue reappears: The problem is in nodes 3-4
|
||||
- If issue still gone: The problem is in nodes 1-2
|
||||
|
||||
5. Let's assume the issue reappeared, so the problem is in nodes 3-4. Test each one:
|
||||
```bash
|
||||
# Move node 3 back to disabled
|
||||
# Linux/Mac
|
||||
mv custom_nodes/node3 ~/custom_nodes_disabled/
|
||||
|
||||
# Windows
|
||||
move custom_nodes\node3 %USERPROFILE%\custom_nodes_disabled\
|
||||
```
|
||||
|
||||
6. Run ComfyUI again
|
||||
- If the issue disappears: node3 is the problem
|
||||
- If issue persists: node4 is the problem
|
||||
|
||||
7. Repeat until you identify the specific problematic node
|
||||
|
||||
#### Step 3: Update or replace the problematic node
|
||||
|
||||
Once identified:
|
||||
1. Check for updates to the problematic custom node
|
||||
2. Consider alternatives with similar functionality
|
||||
3. Report the issue to the custom node developer with specific details
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
- **"Module not found" errors**: Usually indicates missing Python dependencies. Check the custom node's `requirements.txt` file for required packages and install them:
|
||||
```bash
|
||||
pip install -r custom_nodes/problematic_node/requirements.txt
|
||||
```
|
||||
|
||||
- **Frontend or Templates Package Not Updated**: After updating ComfyUI via Git, ensure you update the frontend dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- **Can't Find Custom Node**: Make sure to disable node validation in ComfyUI settings.
|
||||
|
||||
- **Error Toast About Workflow Failing Validation**: Report the issue to the ComfyUI team. As a temporary workaround, disable workflow validation in settings.
|
||||
|
||||
- **Login Issues When Not on Localhost**: Normal login is only available when accessing from localhost. If you're running ComfyUI via LAN, another domain, or headless, you can use our API key feature to authenticate. The API key lets you log in normally through the UI. Generate an API key at [platform.comfy.org/login](https://platform.comfy.org/login) and use it in the API Key field in the login dialog or with the `--api-key` command line argument. Refer to our [API Key Integration Guide](https://docs.comfy.org/essentials/comfyui-server/api-key-integration#integration-of-api-key-to-use-comfyui-api-nodes) for complete setup instructions.
|
||||
@@ -133,6 +133,9 @@ export class ComfyPage {
|
||||
// Inputs
|
||||
public readonly workflowUploadInput: Locator
|
||||
|
||||
// Toasts
|
||||
public readonly visibleToasts: Locator
|
||||
|
||||
// Components
|
||||
public readonly searchBox: ComfyNodeSearchBox
|
||||
public readonly menu: ComfyMenu
|
||||
@@ -159,6 +162,8 @@ export class ComfyPage {
|
||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
@@ -270,7 +275,6 @@ export class ComfyPage {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
localStorage.setItem('api-nodes-news-seen', 'true')
|
||||
}, this.id)
|
||||
}
|
||||
await this.goto()
|
||||
@@ -397,6 +401,30 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async deleteWorkflow(
|
||||
workflowName: string,
|
||||
whenMissing: 'ignoreMissing' | 'throwIfMissing' = 'ignoreMissing'
|
||||
) {
|
||||
// Open workflows tab
|
||||
const { workflowsTab } = this.menu
|
||||
await workflowsTab.open()
|
||||
|
||||
// Action to take if workflow missing
|
||||
if (whenMissing === 'ignoreMissing') {
|
||||
const workflows = await workflowsTab.getTopLevelSavedWorkflowNames()
|
||||
if (!workflows.includes(workflowName)) return
|
||||
}
|
||||
|
||||
// Delete workflow
|
||||
await workflowsTab.getPersistedItem(workflowName).click({ button: 'right' })
|
||||
await this.clickContextMenuItem('Delete')
|
||||
await this.confirmDialog.delete.click()
|
||||
|
||||
// Clear toast & close tab
|
||||
await this.closeToasts(1)
|
||||
await workflowsTab.close()
|
||||
}
|
||||
|
||||
async resetView() {
|
||||
if (await this.resetViewButton.isVisible()) {
|
||||
await this.resetViewButton.click()
|
||||
@@ -413,7 +441,20 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async getVisibleToastCount() {
|
||||
return await this.page.locator('.p-toast-message:visible').count()
|
||||
return await this.visibleToasts.count()
|
||||
}
|
||||
|
||||
async closeToasts(requireCount = 0) {
|
||||
if (requireCount) await expect(this.visibleToasts).toHaveCount(requireCount)
|
||||
|
||||
// Clear all toasts
|
||||
const toastCloseButtons = await this.page
|
||||
.locator('.p-toast-close-button')
|
||||
.all()
|
||||
for (const button of toastCloseButtons) {
|
||||
await button.click()
|
||||
}
|
||||
await expect(this.visibleToasts).toHaveCount(0)
|
||||
}
|
||||
|
||||
async clickTextEncodeNode1() {
|
||||
|
||||
139
browser_tests/tests/chatHistory.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Page, expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
interface ChatHistoryEntry {
|
||||
prompt: string
|
||||
response: string
|
||||
response_id: string
|
||||
}
|
||||
|
||||
async function renderChatHistory(page: Page, history: ChatHistoryEntry[]) {
|
||||
const nodeId = await page.evaluate(() => window['app'].graph.nodes[0]?.id)
|
||||
// Simulate API sending display_component message
|
||||
await page.evaluate(
|
||||
({ nodeId, history }) => {
|
||||
const event = new CustomEvent('display_component', {
|
||||
detail: {
|
||||
node_id: nodeId,
|
||||
component: 'ChatHistoryWidget',
|
||||
props: {
|
||||
history: JSON.stringify(history)
|
||||
}
|
||||
}
|
||||
})
|
||||
window['app'].api.dispatchEvent(event)
|
||||
return true
|
||||
},
|
||||
{ nodeId, history }
|
||||
)
|
||||
|
||||
return nodeId
|
||||
}
|
||||
|
||||
test.describe('Chat History Widget', () => {
|
||||
let nodeId: string
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
nodeId = await renderChatHistory(comfyPage.page, [
|
||||
{ prompt: 'Hello', response: 'World', response_id: '123' }
|
||||
])
|
||||
// Wait for chat history to be rendered
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
})
|
||||
|
||||
test('displays chat history when receiving display_component message', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Verify the chat history is displayed correctly
|
||||
await expect(comfyPage.page.getByText('Hello')).toBeVisible()
|
||||
await expect(comfyPage.page.getByText('World')).toBeVisible()
|
||||
})
|
||||
|
||||
test('handles message editing interaction', async ({ comfyPage }) => {
|
||||
// Get first node's ID
|
||||
nodeId = await comfyPage.page.evaluate(() => {
|
||||
const node = window['app'].graph.nodes[0]
|
||||
|
||||
// Make sure the node has a prompt widget (for editing functionality)
|
||||
if (!node.widgets) {
|
||||
node.widgets = []
|
||||
}
|
||||
|
||||
// Add a prompt widget if it doesn't exist
|
||||
if (!node.widgets.find((w) => w.name === 'prompt')) {
|
||||
node.widgets.push({
|
||||
name: 'prompt',
|
||||
type: 'text',
|
||||
value: 'Original prompt'
|
||||
})
|
||||
}
|
||||
|
||||
return node.id
|
||||
})
|
||||
|
||||
await renderChatHistory(comfyPage.page, [
|
||||
{
|
||||
prompt: 'Message 1',
|
||||
response: 'Response 1',
|
||||
response_id: '123'
|
||||
},
|
||||
{
|
||||
prompt: 'Message 2',
|
||||
response: 'Response 2',
|
||||
response_id: '456'
|
||||
}
|
||||
])
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
|
||||
const originalTextAreaInput = await comfyPage.page
|
||||
.getByPlaceholder('text')
|
||||
.nth(1)
|
||||
.inputValue()
|
||||
|
||||
// Click edit button on first message
|
||||
await comfyPage.page.getByLabel('Edit').first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify cancel button appears
|
||||
await expect(comfyPage.page.getByLabel('Cancel')).toBeVisible()
|
||||
|
||||
// Click cancel edit
|
||||
await comfyPage.page.getByLabel('Cancel').click()
|
||||
|
||||
// Verify prompt input is restored
|
||||
await expect(comfyPage.page.getByPlaceholder('text').nth(1)).toHaveValue(
|
||||
originalTextAreaInput
|
||||
)
|
||||
})
|
||||
|
||||
test('handles real-time updates to chat history', async ({ comfyPage }) => {
|
||||
// Send initial history
|
||||
await renderChatHistory(comfyPage.page, [
|
||||
{
|
||||
prompt: 'Initial message',
|
||||
response: 'Initial response',
|
||||
response_id: '123'
|
||||
}
|
||||
])
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
|
||||
// Update history with additional messages
|
||||
await renderChatHistory(comfyPage.page, [
|
||||
{
|
||||
prompt: 'Follow-up',
|
||||
response: 'New response',
|
||||
response_id: '456'
|
||||
}
|
||||
])
|
||||
await comfyPage.page.waitForSelector('.pi-pencil')
|
||||
|
||||
// Move mouse over the canvas to force update
|
||||
await comfyPage.page.mouse.move(100, 100)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify new messages appear
|
||||
await expect(comfyPage.page.getByText('Follow-up')).toBeVisible()
|
||||
await expect(comfyPage.page.getByText('New response')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
@@ -689,3 +689,42 @@ test.describe('Load duplicate workflow', () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Viewport settings', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
|
||||
|
||||
await comfyPage.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Keeps viewport settings when changing tabs', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
// Screenshot the canvas element
|
||||
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
|
||||
|
||||
// Save workflow as a new file, then zoom out before screen shot
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
|
||||
await comfyMouse.move(comfyPage.emptySpace)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await comfyMouse.wheel(0, 60)
|
||||
}
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
|
||||
|
||||
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
|
||||
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
||||
|
||||
// Go back to Workflow A
|
||||
await tabA.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
|
||||
|
||||
// And back to Workflow B
|
||||
await tabB.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 67 KiB |
1719
package-lock.json
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.19.8",
|
||||
"version": "1.20.3",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -63,6 +63,8 @@
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-vue-devtools": "^7.7.6",
|
||||
"vitest": "^2.0.0",
|
||||
"vue-tsc": "^2.1.10",
|
||||
"zip-dir": "^2.0.0",
|
||||
@@ -72,7 +74,7 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.15.8",
|
||||
"@comfyorg/litegraph": "^0.15.11",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
BIN
public/assets/images/favicon_progress_16x16/frame_0.png
Normal file
|
After Width: | Height: | Size: 647 B |
BIN
public/assets/images/favicon_progress_16x16/frame_1.png
Normal file
|
After Width: | Height: | Size: 674 B |
BIN
public/assets/images/favicon_progress_16x16/frame_2.png
Normal file
|
After Width: | Height: | Size: 674 B |
BIN
public/assets/images/favicon_progress_16x16/frame_3.png
Normal file
|
After Width: | Height: | Size: 674 B |
BIN
public/assets/images/favicon_progress_16x16/frame_4.png
Normal file
|
After Width: | Height: | Size: 698 B |
BIN
public/assets/images/favicon_progress_16x16/frame_5.png
Normal file
|
After Width: | Height: | Size: 700 B |
BIN
public/assets/images/favicon_progress_16x16/frame_6.png
Normal file
|
After Width: | Height: | Size: 702 B |
BIN
public/assets/images/favicon_progress_16x16/frame_7.png
Normal file
|
After Width: | Height: | Size: 705 B |
BIN
public/assets/images/favicon_progress_16x16/frame_8.png
Normal file
|
After Width: | Height: | Size: 708 B |
BIN
public/assets/images/favicon_progress_16x16/frame_9.png
Normal file
|
After Width: | Height: | Size: 705 B |
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- This component does not render anything visible. -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const DEFAULT_TITLE = 'ComfyUI'
|
||||
const TITLE_SUFFIX = ' - ComfyUI'
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const executionText = computed(() =>
|
||||
executionStore.isIdle
|
||||
? ''
|
||||
: `[${Math.round(executionStore.executionProgress * 100)}%]`
|
||||
)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const newMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const isUnsavedText = computed(() =>
|
||||
workflowStore.activeWorkflow?.isModified ||
|
||||
!workflowStore.activeWorkflow?.isPersisted
|
||||
? ' *'
|
||||
: ''
|
||||
)
|
||||
const workflowNameText = computed(() => {
|
||||
const workflowName = workflowStore.activeWorkflow?.filename
|
||||
return workflowName
|
||||
? isUnsavedText.value + workflowName + TITLE_SUFFIX
|
||||
: DEFAULT_TITLE
|
||||
})
|
||||
|
||||
const nodeExecutionTitle = computed(() =>
|
||||
executionStore.executingNode && executionStore.executingNodeProgress
|
||||
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const workflowTitle = computed(
|
||||
() =>
|
||||
executionText.value +
|
||||
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
|
||||
)
|
||||
|
||||
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
|
||||
useTitle(title)
|
||||
</script>
|
||||
@@ -63,15 +63,6 @@ watchDebounced(
|
||||
|
||||
// Set initial position to bottom center
|
||||
const setInitialPosition = () => {
|
||||
if (x.value !== 0 || y.value !== 0) {
|
||||
return
|
||||
}
|
||||
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
|
||||
x.value = storedPosition.value.x
|
||||
y.value = storedPosition.value.y
|
||||
captureLastDragState()
|
||||
return
|
||||
}
|
||||
if (panelRef.value) {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
@@ -82,9 +73,25 @@ const setInitialPosition = () => {
|
||||
return
|
||||
}
|
||||
|
||||
x.value = (screenWidth - menuWidth) / 2
|
||||
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
|
||||
captureLastDragState()
|
||||
// Check if stored position exists and is within bounds
|
||||
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
|
||||
// Ensure stored position is within screen bounds
|
||||
x.value = clamp(storedPosition.value.x, 0, screenWidth - menuWidth)
|
||||
y.value = clamp(storedPosition.value.y, 0, screenHeight - menuHeight)
|
||||
captureLastDragState()
|
||||
return
|
||||
}
|
||||
|
||||
// If no stored position or current position, set to bottom center
|
||||
if (x.value === 0 && y.value === 0) {
|
||||
x.value = clamp((screenWidth - menuWidth) / 2, 0, screenWidth - menuWidth)
|
||||
y.value = clamp(
|
||||
screenHeight - menuHeight - 10,
|
||||
0,
|
||||
screenHeight - menuHeight
|
||||
)
|
||||
captureLastDragState()
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(setInitialPosition)
|
||||
|
||||
@@ -70,11 +70,12 @@ const state = computed<GridState>(() => {
|
||||
const fromCol = fromRow * cols.value
|
||||
const toCol = toRow * cols.value
|
||||
const remainingCol = items.length - toCol
|
||||
const hasMoreToRender = remainingCol >= 0
|
||||
|
||||
return {
|
||||
start: clamp(fromCol, 0, items?.length),
|
||||
end: clamp(toCol, fromCol, items?.length),
|
||||
isNearEnd: remainingCol <= cols.value * bufferRows
|
||||
isNearEnd: hasMoreToRender && remainingCol <= cols.value * bufferRows
|
||||
}
|
||||
})
|
||||
const renderedItems = computed(() =>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-12 p-2 w-96">
|
||||
<img src="@/assets/images/api-nodes-news.webp" alt="API Nodes News" />
|
||||
<div class="flex flex-col gap-2 justify-center items-center">
|
||||
<div class="text-xl">
|
||||
{{ $t('apiNodesNews.introducing') }}
|
||||
<span class="text-amber-500">API NODES</span>
|
||||
</div>
|
||||
<div class="text-muted">{{ $t('apiNodesNews.subtitle') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="grid grid-cols-[auto_1fr] gap-2 items-center"
|
||||
>
|
||||
<Tag class="w-8 h-8" :value="index + 1" rounded />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>{{ step.title }}</div>
|
||||
<div v-if="step.subtitle" class="text-muted">
|
||||
{{ step.subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-between">
|
||||
<Button label="Learn More" text @click="handleLearnMore" />
|
||||
<Button label="Close" @click="onClose" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const steps: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
}[] = [
|
||||
{
|
||||
title: t('apiNodesNews.steps.step1.title'),
|
||||
subtitle: t('apiNodesNews.steps.step1.subtitle')
|
||||
},
|
||||
{
|
||||
title: t('apiNodesNews.steps.step2.title'),
|
||||
subtitle: t('apiNodesNews.steps.step2.subtitle')
|
||||
},
|
||||
{
|
||||
title: t('apiNodesNews.steps.step3.title')
|
||||
},
|
||||
{
|
||||
title: t('apiNodesNews.steps.step4.title')
|
||||
}
|
||||
]
|
||||
|
||||
const { onClose } = defineProps<{
|
||||
onClose: () => void
|
||||
}>()
|
||||
|
||||
const handleLearnMore = () => {
|
||||
window.open('https://blog.comfy.org/p/comfyui-native-api-nodes', '_blank')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
localStorage.setItem('api-nodes-news-seen', 'true')
|
||||
})
|
||||
</script>
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
<div class="flex justify-end py-3">
|
||||
<div v-if="isManagerInstalled" class="flex justify-end py-3">
|
||||
<Button label="Open Manager" size="small" outlined @click="openManager" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,6 +42,7 @@ import { computed } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
|
||||
@@ -49,6 +50,19 @@ const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
|
||||
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
|
||||
// This allows us to conditionally show the Manager button only when the extension is available
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
const isManagerInstalled = computed(() => {
|
||||
return aboutPanelStore.badges.some(
|
||||
(badge) =>
|
||||
badge.label.includes('ComfyUI-Manager') ||
|
||||
badge.url.includes('ComfyUI-Manager')
|
||||
)
|
||||
})
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
const seenTypes = new Set()
|
||||
return props.missingNodeTypes
|
||||
|
||||
@@ -67,9 +67,9 @@ import Tabs from 'primevue/tabs'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSettingSearch } from '@/composables/setting/useSettingSearch'
|
||||
import { useSettingUI } from '@/composables/setting/useSettingUI'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { SettingTreeNode } from '@/stores/settingStore'
|
||||
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
|
||||
import { flattenTree } from '@/utils/treeUtil'
|
||||
@@ -107,7 +107,7 @@ const {
|
||||
getSearchResults
|
||||
} = useSettingSearch()
|
||||
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
// Sort groups for a category
|
||||
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
|
||||
@@ -140,7 +140,7 @@ watch(activeCategory, (_, oldValue) => {
|
||||
activeCategory.value = oldValue
|
||||
}
|
||||
if (activeCategory.value?.key === 'credits') {
|
||||
void authService.fetchBalance()
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -101,6 +101,15 @@
|
||||
{{ t('auth.apiKey.generateKey') }}
|
||||
</a>
|
||||
</small>
|
||||
<Message
|
||||
v-if="authActions.accessError.value"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
variant="outlined"
|
||||
closable
|
||||
>
|
||||
{{ t('toastMessages.useApiKeyTip') }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
@@ -134,12 +143,12 @@
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Message from 'primevue/message'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
|
||||
import { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
|
||||
import ApiKeyForm from './signin/ApiKeyForm.vue'
|
||||
@@ -151,7 +160,7 @@ const { onSuccess } = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const isSecureContext = window.isSecureContext
|
||||
const isSignIn = ref(true)
|
||||
const showApiKeyForm = ref(false)
|
||||
@@ -162,25 +171,25 @@ const toggleState = () => {
|
||||
}
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
if (await authService.signInWithGoogle()) {
|
||||
if (await authActions.signInWithGoogle()) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithGithub = async () => {
|
||||
if (await authService.signInWithGithub()) {
|
||||
if (await authActions.signInWithGithub()) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithEmail = async (values: SignInData) => {
|
||||
if (await authService.signInWithEmail(values.email, values.password)) {
|
||||
if (await authActions.signInWithEmail(values.email, values.password)) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData) => {
|
||||
if (await authService.signUpWithEmail(values.email, values.password)) {
|
||||
if (await authActions.signUpWithEmail(values.email, values.password)) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
@@ -189,4 +198,8 @@ const userIsInChina = ref(false)
|
||||
onMounted(async () => {
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
authActions.accessError.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
|
||||
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
|
||||
|
||||
@@ -65,9 +65,9 @@ const {
|
||||
preselectedAmountOption?: number
|
||||
}>()
|
||||
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
const handleSeeDetails = async () => {
|
||||
await authService.accessBillingPortal()
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,10 +23,10 @@ import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { updatePasswordSchema } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const loading = ref(false)
|
||||
|
||||
const { onSuccess } = defineProps<{
|
||||
@@ -37,7 +37,7 @@ const onSubmit = async (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
await authService.updatePassword(event.values.password)
|
||||
await authActions.updatePassword(event.values.password)
|
||||
onSuccess()
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -41,9 +41,9 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tag from 'primevue/tag'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
const {
|
||||
amount,
|
||||
@@ -61,7 +61,7 @@ const loading = ref(false)
|
||||
|
||||
const handleBuyNow = async () => {
|
||||
loading.value = true
|
||||
await authService.purchaseCredits(editable ? customAmount.value : amount)
|
||||
await authActions.purchaseCredits(editable ? customAmount.value : amount)
|
||||
loading.value = false
|
||||
didClickBuyNow.value = true
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const handleBuyNow = async () => {
|
||||
onBeforeUnmount(() => {
|
||||
if (didClickBuyNow.value) {
|
||||
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
|
||||
void authService.fetchBalance()
|
||||
void authActions.fetchBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
/>
|
||||
<div v-else class="h-full" @click="handleGridContainerClick">
|
||||
<VirtualGrid
|
||||
id="results-grid"
|
||||
:items="resultsWithKeys"
|
||||
:buffer-rows="3"
|
||||
:grid-style="GRID_STYLE"
|
||||
@@ -92,7 +93,7 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { merge } from 'lodash'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
@@ -199,6 +200,10 @@ const {
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
whenever(selectedTab, () => {
|
||||
pageNumber.value = 0
|
||||
})
|
||||
|
||||
const isUpdateAvailableTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
|
||||
)
|
||||
@@ -419,6 +424,17 @@ whenever(selectedNodePack, async () => {
|
||||
}
|
||||
})
|
||||
|
||||
let gridContainer: HTMLElement | null = null
|
||||
onMounted(() => {
|
||||
gridContainer = document.getElementById('results-grid')
|
||||
})
|
||||
watch(searchQuery, () => {
|
||||
gridContainer ??= document.getElementById('results-grid')
|
||||
if (gridContainer) {
|
||||
gridContainer.scrollTop = 0
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
getPackById.cancel()
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
style: 'display: none'
|
||||
}
|
||||
}"
|
||||
:show-empty-message="false"
|
||||
@complete="stubTrue"
|
||||
@option-select="onOptionSelect"
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
text
|
||||
size="small"
|
||||
severity="secondary"
|
||||
@click="() => authService.fetchBalance()"
|
||||
@click="() => authActions.fetchBalance()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,8 +112,8 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
@@ -127,7 +127,7 @@ interface CreditHistoryItemData {
|
||||
const { t } = useI18n()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
@@ -142,7 +142,7 @@ const handlePurchaseCreditsClick = () => {
|
||||
}
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authService.accessBillingPortal()
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = () => {
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
<h3 class="font-medium">
|
||||
{{ $t('userSettings.email') }}
|
||||
</h3>
|
||||
<a :href="'mailto:' + userEmail" class="hover:underline">
|
||||
<span class="text-muted">
|
||||
{{ userEmail }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
|
||||
@@ -80,12 +80,12 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { type SignInData, signInSchema } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const firebaseAuthService = useFirebaseAuthService()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -102,6 +102,6 @@ const onSubmit = (event: FormSubmitEvent) => {
|
||||
|
||||
const handleForgotPassword = async (email: string) => {
|
||||
if (!email) return
|
||||
await firebaseAuthService.sendPasswordReset(email)
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,16 +12,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, watch } from 'vue'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { type DomWidgetState, useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const widgetStates = computed(
|
||||
() => Array.from(domWidgetStore.widgetStates.values()) as DomWidgetState[]
|
||||
const widgetStates = computed(() =>
|
||||
Array.from(domWidgetStore.widgetStates.values())
|
||||
)
|
||||
|
||||
const updateWidgets = () => {
|
||||
@@ -54,18 +55,13 @@ const updateWidgets = () => {
|
||||
}
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
watch(
|
||||
whenever(
|
||||
() => canvasStore.canvas,
|
||||
(lgCanvas) => {
|
||||
if (!lgCanvas) return
|
||||
|
||||
lgCanvas.onDrawForeground = useChainCallback(
|
||||
lgCanvas.onDrawForeground,
|
||||
() => {
|
||||
updateWidgets()
|
||||
}
|
||||
)
|
||||
},
|
||||
(canvas) =>
|
||||
(canvas.onDrawForeground = useChainCallback(
|
||||
canvas.onDrawForeground,
|
||||
updateWidgets
|
||||
)),
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
class="w-full h-full touch-none"
|
||||
/>
|
||||
|
||||
<NodeBadge />
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover />
|
||||
|
||||
@@ -53,7 +52,6 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import NodeBadge from '@/components/graph/NodeBadge.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
@@ -62,6 +60,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
||||
import { useCopy } from '@/composables/useCopy'
|
||||
@@ -71,7 +70,7 @@ import { usePaste } from '@/composables/usePaste'
|
||||
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n } from '@/i18n'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -225,39 +224,13 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Save the drag & scale info in the serialized workflow if the setting is enabled
|
||||
watch(
|
||||
[
|
||||
() => canvasStore.canvas,
|
||||
() => settingStore.get('Comfy.EnableWorkflowViewRestore')
|
||||
],
|
||||
([canvas, enableWorkflowViewRestore]) => {
|
||||
const extra = canvas?.graph?.extra
|
||||
if (!extra) return
|
||||
|
||||
if (enableWorkflowViewRestore) {
|
||||
extra.ds = {
|
||||
get scale() {
|
||||
return canvas.ds.scale
|
||||
},
|
||||
get offset() {
|
||||
const [x, y] = canvas.ds.offset
|
||||
return [x, y]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delete extra.ds
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
canvasRef,
|
||||
'litegraph:no-items-selected',
|
||||
() => {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: 'No items selected',
|
||||
summary: t('toastMessages.nothingSelected'),
|
||||
life: 2000
|
||||
})
|
||||
},
|
||||
@@ -280,6 +253,7 @@ const workflowPersistence = useWorkflowPersistence()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
|
||||
onMounted(async () => {
|
||||
useGlobalLitegraph()
|
||||
@@ -293,7 +267,7 @@ onMounted(async () => {
|
||||
workspaceStore.spinner = true
|
||||
// ChangeTracker needs to be initialized before setup, as it will overwrite
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init(comfyApp)
|
||||
ChangeTracker.init()
|
||||
await loadCustomNodesI18n()
|
||||
try {
|
||||
await settingStore.loadSettingValues()
|
||||
@@ -318,10 +292,8 @@ onMounted(async () => {
|
||||
canvasStore.canvas.render_canvas_border = false
|
||||
workspaceStore.spinner = false
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
window['app'] = comfyApp
|
||||
// @ts-expect-error fixme ts strict error
|
||||
window['graph'] = comfyApp.graph
|
||||
window.app = comfyApp
|
||||
window.graph = comfyApp.graph
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- This component does not render anything visible. -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BadgePosition,
|
||||
LGraphBadge,
|
||||
type LGraphNode
|
||||
} from '@comfyorg/litegraph'
|
||||
import _ from 'lodash'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const nodeSourceBadgeMode = computed(
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeIdBadgeMode = computed(
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeLifeCycleBadgeMode = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
|
||||
watch([nodeSourceBadgeMode, nodeIdBadgeMode, nodeLifeCycleBadgeMode], () => {
|
||||
app.graph?.setDirtyCanvas(true, true)
|
||||
})
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
function badgeTextVisible(
|
||||
nodeDef: ComfyNodeDefImpl | null,
|
||||
badgeMode: NodeBadgeMode
|
||||
): boolean {
|
||||
return !(
|
||||
badgeMode === NodeBadgeMode.None ||
|
||||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
app.registerExtension({
|
||||
name: 'Comfy.NodeBadge',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
node.badgePosition = BadgePosition.TopRight
|
||||
|
||||
const badge = computed(() => {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(node)
|
||||
return new LGraphBadge({
|
||||
text: _.truncate(
|
||||
[
|
||||
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
|
||||
? `#${node.id}`
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
|
||||
? nodeDef?.nodeLifeCycleBadgeText ?? ''
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
|
||||
? nodeDef?.nodeSource?.badgeText ?? ''
|
||||
: ''
|
||||
]
|
||||
.filter((s) => s.length > 0)
|
||||
.join(' '),
|
||||
{
|
||||
length: 31
|
||||
}
|
||||
),
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_BG_COLOR
|
||||
})
|
||||
})
|
||||
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node) {
|
||||
const creditsBadge = computed(() => {
|
||||
return new LGraphBadge({
|
||||
text: '',
|
||||
iconOptions: {
|
||||
unicode: '\ue96b',
|
||||
fontFamily: 'PrimeIcons',
|
||||
color: '#FABC25',
|
||||
bgColor: '#353535',
|
||||
fontSize: 8
|
||||
},
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_BG_COLOR
|
||||
})
|
||||
})
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -14,12 +14,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createBounds } from '@comfyorg/litegraph'
|
||||
import type { LGraphCanvas } from '@comfyorg/litegraph'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -28,8 +26,8 @@ const { style, updatePosition } = useAbsolutePosition()
|
||||
const visible = ref(false)
|
||||
const showBorder = ref(false)
|
||||
|
||||
const positionSelectionOverlay = (canvas: LGraphCanvas) => {
|
||||
const selectedItems = canvas.selectedItems
|
||||
const positionSelectionOverlay = () => {
|
||||
const { selectedItems } = canvasStore.getCanvas()
|
||||
showBorder.value = selectedItems.size > 1
|
||||
|
||||
if (!selectedItems.size) {
|
||||
@@ -48,26 +46,18 @@ const positionSelectionOverlay = (canvas: LGraphCanvas) => {
|
||||
}
|
||||
|
||||
// Register listener on canvas creation.
|
||||
watch(
|
||||
() => canvasStore.canvas as LGraphCanvas | null,
|
||||
(canvas: LGraphCanvas | null) => {
|
||||
if (!canvas) return
|
||||
|
||||
canvas.onSelectionChange = useChainCallback(
|
||||
canvas.onSelectionChange,
|
||||
// Wait for next frame as sometimes the selected items haven't been
|
||||
// rendered yet, so the boundingRect is not available on them.
|
||||
() => requestAnimationFrame(() => positionSelectionOverlay(canvas))
|
||||
)
|
||||
whenever(
|
||||
() => canvasStore.getCanvas().state.selectionChanged,
|
||||
() => {
|
||||
requestAnimationFrame(() => {
|
||||
positionSelectionOverlay()
|
||||
canvasStore.getCanvas().state.selectionChanged = false
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => canvasStore.getCanvas().ds.state,
|
||||
() => positionSelectionOverlay(canvasStore.getCanvas()),
|
||||
{ deep: true }
|
||||
)
|
||||
canvasStore.getCanvas().ds.onChanged = positionSelectionOverlay
|
||||
|
||||
watch(
|
||||
() => canvasStore.canvas?.state?.draggingItems,
|
||||
@@ -77,10 +67,10 @@ watch(
|
||||
// the correct position.
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/2656
|
||||
if (draggingItems === false) {
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
visible.value = true
|
||||
positionSelectionOverlay(canvasStore.canvas as LGraphCanvas)
|
||||
}, 100)
|
||||
positionSelectionOverlay()
|
||||
})
|
||||
} else {
|
||||
// Selection change update to visible state is delayed by a frame. Here
|
||||
// we also delay a frame so that the order of events is correct when
|
||||
|
||||
@@ -6,96 +6,41 @@
|
||||
content: 'p-0 flex flex-row'
|
||||
}"
|
||||
>
|
||||
<ExecuteButton v-show="nodeSelected" />
|
||||
<ColorPickerButton v-show="nodeSelected || groupSelected" />
|
||||
<Button
|
||||
v-show="nodeSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
data-testid="bypass-button"
|
||||
@click="
|
||||
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<i-game-icons:detour />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-show="nodeSelected || groupSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-thumbtack"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="danger"
|
||||
text
|
||||
icon="pi pi-trash"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
|
||||
/>
|
||||
<Button
|
||||
v-show="isRefreshable"
|
||||
severity="info"
|
||||
text
|
||||
icon="pi pi-refresh"
|
||||
@click="refreshSelected"
|
||||
/>
|
||||
<Button
|
||||
<ExecuteButton />
|
||||
<ColorPickerButton />
|
||||
<BypassButton />
|
||||
<PinButton />
|
||||
<DeleteButton />
|
||||
<RefreshButton />
|
||||
<ExtensionCommandButton
|
||||
v-for="command in extensionToolboxCommands"
|
||||
:key="command.id"
|
||||
v-tooltip.top="{
|
||||
value:
|
||||
st(`commands.${normalizeI18nKey(command.id)}.label`, '') || undefined,
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
|
||||
@click="() => commandStore.execute(command.id)"
|
||||
:command="command"
|
||||
/>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
||||
import { st, t } from '@/i18n'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
import BypassButton from './selectionToolbox/BypassButton.vue'
|
||||
import DeleteButton from './selectionToolbox/DeleteButton.vue'
|
||||
import ExtensionCommandButton from './selectionToolbox/ExtensionCommandButton.vue'
|
||||
import PinButton from './selectionToolbox/PinButton.vue'
|
||||
import RefreshButton from './selectionToolbox/RefreshButton.vue'
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const extensionService = useExtensionService()
|
||||
const { isRefreshable, refreshSelected } = useRefreshableSelection()
|
||||
const nodeSelected = computed(() =>
|
||||
canvasStore.selectedItems.some(isLGraphNode)
|
||||
)
|
||||
const groupSelected = computed(() =>
|
||||
canvasStore.selectedItems.some(isLGraphGroup)
|
||||
)
|
||||
|
||||
const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
|
||||
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
const commandIds = new Set<string>(
|
||||
canvasStore.selectedItems
|
||||
.map(
|
||||
@@ -108,7 +53,7 @@ const extensionToolboxCommands = computed<ComfyCommand[]>(() => {
|
||||
)
|
||||
return Array.from(commandIds)
|
||||
.map((commandId) => commandStore.getCommand(commandId))
|
||||
.filter((command) => command !== undefined)
|
||||
.filter((command): command is ComfyCommandImpl => command !== undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
31
src/components/graph/selectionToolbox/BypassButton.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Bypass.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
data-testid="bypass-button"
|
||||
@click="
|
||||
() => commandStore.execute('Comfy.Canvas.ToggleSelectedNodes.Bypass')
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<i-game-icons:detour />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="() => (showColorPicker = !showColorPicker)"
|
||||
|
||||
22
src/components/graph/selectionToolbox/DeleteButton.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="danger"
|
||||
text
|
||||
icon="pi pi-trash"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.DeleteSelectedItems')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected"
|
||||
v-tooltip.top="{
|
||||
value: isDisabled
|
||||
? t('selectionToolbox.executeButton.disabledTooltip')
|
||||
@@ -36,7 +37,7 @@ const buttonHovered = ref(false)
|
||||
const selectedOutputNodes = computed(
|
||||
() =>
|
||||
canvasStore.selectedItems.filter(
|
||||
(item) => isLGraphNode(item) && item.constructor.nodeData.output_node
|
||||
(item) => isLGraphNode(item) && item.constructor.nodeData?.output_node
|
||||
) as LGraphNode[]
|
||||
)
|
||||
|
||||
@@ -45,7 +46,7 @@ const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
|
||||
function outputNodeStokeStyle(this: LGraphNode) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.constructor.nodeData.output_node &&
|
||||
this.constructor.nodeData?.output_node &&
|
||||
buttonHovered.value
|
||||
) {
|
||||
return { color: 'orange', lineWidth: 2, padding: 10 }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value:
|
||||
st(`commands.${normalizeI18nKey(command.id)}.label`, '') || undefined,
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
|
||||
@click="() => commandStore.execute(command.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { ComfyCommand, useCommandStore } from '@/stores/commandStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
defineProps<{
|
||||
command: ComfyCommand
|
||||
}>()
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
</script>
|
||||
25
src/components/graph/selectionToolbox/PinButton.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
|
||||
v-tooltip.top="{
|
||||
value: t('commands.Comfy_Canvas_ToggleSelectedNodes_Pin.label'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
severity="secondary"
|
||||
text
|
||||
icon="pi pi-thumbtack"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleSelected.Pin')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
</script>
|
||||
17
src/components/graph/selectionToolbox/RefreshButton.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="isRefreshable"
|
||||
severity="info"
|
||||
text
|
||||
icon="pi pi-refresh"
|
||||
@click="refreshSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
||||
|
||||
const { isRefreshable, refreshSelected } = useRefreshableSelection()
|
||||
</script>
|
||||
134
src/components/graph/widgets/ChatHistoryWidget.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<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) => {
|
||||
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
|
||||
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>
|
||||
@@ -11,6 +11,7 @@
|
||||
v-if="isComponentWidget(widget)"
|
||||
:model-value="widget.value"
|
||||
:widget="widget"
|
||||
v-bind="widget.props"
|
||||
@update:model-value="emit('update:widgetValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
36
src/components/graph/widgets/chatHistory/CopyButton.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip="
|
||||
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
|
||||
"
|
||||
text
|
||||
rounded
|
||||
class="!p-1 !h-4 !w-6 text-gray-400 hover:text-gray-600 dark-theme:hover:text-gray-200 transition"
|
||||
pt:icon:class="!text-xs"
|
||||
:icon="copied ? 'pi pi-check' : 'pi pi-copy'"
|
||||
:aria-label="
|
||||
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
|
||||
"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { text } = defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!text) return
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 1024)
|
||||
}
|
||||
</script>
|
||||
22
src/components/graph/widgets/chatHistory/ResponseBlurb.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<span>
|
||||
<div class="flex justify-start mb-1">
|
||||
<div class="rounded-xl px-4 py-1 max-w-[80%]">
|
||||
<div class="break-words text-[12px]">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start ml-1">
|
||||
<CopyButton :text="text" />
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
|
||||
|
||||
defineProps<{
|
||||
text: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -52,6 +52,9 @@ const eventConfig = {
|
||||
showPreviewChange: (value: boolean) => emit('showPreviewChange', value),
|
||||
backgroundImageChange: (value: string) =>
|
||||
emit('backgroundImageChange', value),
|
||||
backgroundImageLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.loadingBackgroundImage')),
|
||||
backgroundImageLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
upDirectionChange: (value: string) => emit('upDirectionChange', value),
|
||||
edgeThresholdChange: (value: number) => emit('edgeThresholdChange', value),
|
||||
modelLoadingStart: () =>
|
||||
@@ -75,7 +78,7 @@ const eventConfig = {
|
||||
|
||||
watchEffect(async () => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value)
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setBackgroundColor(props.backgroundColor)
|
||||
rawLoad3d.toggleGrid(props.showGrid)
|
||||
@@ -84,15 +87,25 @@ watchEffect(async () => {
|
||||
rawLoad3d.toggleCamera(props.cameraType)
|
||||
rawLoad3d.togglePreview(props.showPreview)
|
||||
await rawLoad3d.setBackgroundImage(props.backgroundImage)
|
||||
rawLoad3d.setUpDirection(props.upDirection)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.upDirection,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setUpDirection(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.materialMode,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value)
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
rawLoad3d.setMaterialMode(newValue)
|
||||
}
|
||||
@@ -102,10 +115,9 @@ watch(
|
||||
watch(
|
||||
() => props.edgeThreshold,
|
||||
(newValue) => {
|
||||
if (load3d.value) {
|
||||
const rawLoad3d = toRaw(load3d.value)
|
||||
if (load3d.value && newValue) {
|
||||
const rawLoad3d = toRaw(load3d.value) as Load3d
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
rawLoad3d.setEdgeThreshold(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
122
src/components/topbar/CurrentUserButton.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import CurrentUserButton from './CurrentUserButton.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia')
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
isLoggedIn: true,
|
||||
userPhotoUrl: 'https://example.com/avatar.jpg',
|
||||
userDisplayName: 'Test User',
|
||||
userEmail: 'test@example.com'
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the UserAvatar component
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: {
|
||||
name: 'UserAvatarMock',
|
||||
render() {
|
||||
return h('div', 'Avatar')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the CurrentUserPopover component
|
||||
vi.mock('./CurrentUserPopover.vue', () => ({
|
||||
default: {
|
||||
name: 'CurrentUserPopoverMock',
|
||||
render() {
|
||||
return h('div', 'Popover Content')
|
||||
},
|
||||
emits: ['close']
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CurrentUserButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(CurrentUserButton, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
// Use shallow mount for popover to make testing easier
|
||||
Popover: {
|
||||
template: '<div><slot></slot></div>',
|
||||
methods: {
|
||||
toggle: vi.fn(),
|
||||
hide: vi.fn()
|
||||
}
|
||||
},
|
||||
Button: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders correctly when user is logged in', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles popover on button click', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const popoverToggleSpy = vi.fn()
|
||||
|
||||
// Override the ref with a mock implementation
|
||||
// @ts-expect-error - accessing internal Vue component vm
|
||||
wrapper.vm.popover = { toggle: popoverToggleSpy }
|
||||
|
||||
await wrapper.findComponent(Button).trigger('click')
|
||||
expect(popoverToggleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides popover when closePopover is called', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Replace the popover.hide method with a spy
|
||||
const popoverHideSpy = vi.fn()
|
||||
// @ts-expect-error - accessing internal Vue component vm
|
||||
wrapper.vm.popover = { hide: popoverHideSpy }
|
||||
|
||||
// Directly call the closePopover method through the component instance
|
||||
// @ts-expect-error - accessing internal Vue component vm
|
||||
wrapper.vm.closePopover()
|
||||
|
||||
// Verify that popover.hide was called
|
||||
expect(popoverHideSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@
|
||||
</Button>
|
||||
|
||||
<Popover ref="popover" :show-arrow="false">
|
||||
<CurrentUserPopover />
|
||||
<CurrentUserPopover @close="closePopover" />
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -40,4 +40,8 @@ const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const closePopover = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
</script>
|
||||
|
||||
173
src/components/topbar/CurrentUserPopover.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { VueWrapper, mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
initializeApp: vi.fn(),
|
||||
getApp: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(),
|
||||
setPersistence: vi.fn(),
|
||||
browserLocalPersistence: {},
|
||||
onAuthStateChanged: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock pinia
|
||||
vi.mock('pinia')
|
||||
|
||||
// Mock showSettingsDialog and showTopUpCreditsDialog
|
||||
const mockShowSettingsDialog = vi.fn()
|
||||
const mockShowTopUpCreditsDialog = vi.fn()
|
||||
|
||||
// Mock window.open
|
||||
const originalWindowOpen = window.open
|
||||
beforeEach(() => {
|
||||
window.open = vi.fn()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
window.open = originalWindowOpen
|
||||
})
|
||||
|
||||
// Mock the useCurrentUser composable
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
userPhotoUrl: 'https://example.com/avatar.jpg',
|
||||
userDisplayName: 'Test User',
|
||||
userEmail: 'test@example.com'
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the useFirebaseAuthActions composable
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
fetchBalance: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the dialog service
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showSettingsDialog: mockShowSettingsDialog,
|
||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock UserAvatar component
|
||||
vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
default: {
|
||||
name: 'UserAvatarMock',
|
||||
render() {
|
||||
return h('div', 'Avatar')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock UserCredit component
|
||||
vi.mock('@/components/common/UserCredit.vue', () => ({
|
||||
default: {
|
||||
name: 'UserCreditMock',
|
||||
render() {
|
||||
return h('div', 'Credit: 100')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopover', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(CurrentUserPopover, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
Divider: true,
|
||||
Button: true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders user information correctly', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.text()).toContain('Test User')
|
||||
expect(wrapper.text()).toContain('test@example.com')
|
||||
})
|
||||
|
||||
it('opens user settings and emits close event when settings button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the settings button (first one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const settingsButton = buttons[0]
|
||||
|
||||
// Click the settings button
|
||||
await settingsButton.trigger('click')
|
||||
|
||||
// Verify showSettingsDialog was called with 'user'
|
||||
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the API pricing button (second one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const apiPricingButton = buttons[1]
|
||||
|
||||
// Click the API pricing button
|
||||
await apiPricingButton.trigger('click')
|
||||
|
||||
// Verify window.open was called with the correct URL
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://docs.comfy.org/tutorials/api-nodes/pricing',
|
||||
'_blank'
|
||||
)
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the top-up button (last one)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const topUpButton = buttons[buttons.length - 1]
|
||||
|
||||
// Click the top-up button
|
||||
await topUpButton.trigger('click')
|
||||
|
||||
// Verify showTopUpCreditsDialog was called
|
||||
expect(mockShowTopUpCreditsDialog).toHaveBeenCalled()
|
||||
|
||||
// Verify close event was emitted
|
||||
expect(wrapper.emitted('close')).toBeTruthy()
|
||||
expect(wrapper.emitted('close')!.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -69,26 +69,33 @@ import { onMounted } from 'vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
|
||||
const authService = useFirebaseAuthService()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenApiPricing = () => {
|
||||
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authService.fetchBalance()
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
310
src/composables/README.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Composables
|
||||
|
||||
This directory contains Vue composables for the ComfyUI frontend application. Composables are reusable pieces of logic that encapsulate stateful functionality and can be shared across components.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Composable Architecture](#composable-architecture)
|
||||
- [Composable Categories](#composable-categories)
|
||||
- [Usage Guidelines](#usage-guidelines)
|
||||
- [VueUse Library](#vueuse-library)
|
||||
- [Development Guidelines](#development-guidelines)
|
||||
- [Common Patterns](#common-patterns)
|
||||
|
||||
## Overview
|
||||
|
||||
Vue composables are a core part of Vue 3's Composition API and provide a way to extract and reuse stateful logic between multiple components. In ComfyUI, composables are used to encapsulate behaviors like:
|
||||
|
||||
- State management
|
||||
- DOM interactions
|
||||
- Feature-specific functionality
|
||||
- UI behaviors
|
||||
- Data fetching
|
||||
|
||||
Composables enable a more modular and functional approach to building components, allowing for better code reuse and separation of concerns. They help keep your component code cleaner by extracting complex logic into separate, reusable functions.
|
||||
|
||||
As described in the [Vue.js documentation](https://vuejs.org/guide/reusability/composables.html), composables are:
|
||||
> Functions that leverage Vue's Composition API to encapsulate and reuse stateful logic.
|
||||
|
||||
## Composable Architecture
|
||||
|
||||
The composable architecture in ComfyUI follows these principles:
|
||||
|
||||
1. **Single Responsibility**: Each composable should focus on a specific concern
|
||||
2. **Composition**: Composables can use other composables
|
||||
3. **Reactivity**: Composables leverage Vue's reactivity system
|
||||
4. **Reusability**: Composables are designed to be used across multiple components
|
||||
|
||||
The following diagram shows how composables fit into the application architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Vue Components │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Component A │ │ Component B │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │
|
||||
└────────────┼───────────────────┼────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────┴───────────────────┴────────────────────────┐
|
||||
│ Composables │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ useFeatureA │ │ useFeatureB │ │ useFeatureC │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────┼────────────────┼────────────────┼─────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┴────────────────┴────────────────┴─────────────┐
|
||||
│ Services & Stores │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Composable Categories
|
||||
|
||||
ComfyUI's composables are organized into several categories:
|
||||
|
||||
### Auth
|
||||
|
||||
Composables for authentication and user management:
|
||||
- `useCurrentUser` - Provides access to the current user information
|
||||
- `useFirebaseAuthActions` - Handles Firebase authentication operations
|
||||
|
||||
### Element
|
||||
|
||||
Composables for DOM and element interactions:
|
||||
- `useAbsolutePosition` - Handles element positioning
|
||||
- `useDomClipping` - Manages clipping of DOM elements
|
||||
- `useResponsiveCollapse` - Manages responsive collapsing of elements
|
||||
|
||||
### Node
|
||||
|
||||
Composables for node-specific functionality:
|
||||
- `useNodeBadge` - Handles node badge display and interaction
|
||||
- `useNodeImage` - Manages node image preview
|
||||
- `useNodeDragAndDrop` - Handles drag and drop for nodes
|
||||
- `useNodeChatHistory` - Manages chat history for nodes
|
||||
|
||||
### Settings
|
||||
|
||||
Composables for settings management:
|
||||
- `useSettingSearch` - Provides search functionality for settings
|
||||
- `useSettingUI` - Manages settings UI interactions
|
||||
|
||||
### Sidebar
|
||||
|
||||
Composables for sidebar functionality:
|
||||
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
|
||||
- `useQueueSidebarTab` - Manages the queue sidebar tab
|
||||
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
|
||||
|
||||
### Widgets
|
||||
|
||||
Composables for widget functionality:
|
||||
- `useBooleanWidget` - Manages boolean widget interactions
|
||||
- `useComboWidget` - Manages combo box widget interactions
|
||||
- `useFloatWidget` - Manages float input widget interactions
|
||||
- `useImagePreviewWidget` - Manages image preview widget
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
When using composables in components, follow these guidelines:
|
||||
|
||||
1. **Import and call** composables at the top level of the `setup` function
|
||||
2. **Destructure returned values** to use in your component
|
||||
3. **Respect reactivity** by not destructuring reactive objects
|
||||
4. **Handle cleanup** by using `onUnmounted` when necessary
|
||||
5. **Use VueUse** for common functionality instead of writing from scratch
|
||||
|
||||
Example usage:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'dragging': isDragging }"
|
||||
@mousedown="startDrag"
|
||||
@mouseup="endDrag"
|
||||
>
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Node preview" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop';
|
||||
import { useNodeImage } from '@/composables/node/useNodeImage';
|
||||
|
||||
// Use composables at the top level
|
||||
const { isDragging, startDrag, endDrag } = useNodeDragAndDrop();
|
||||
const { imageUrl, loadImage } = useNodeImage();
|
||||
|
||||
// Use returned values in your component
|
||||
</script>
|
||||
```
|
||||
|
||||
## VueUse Library
|
||||
|
||||
ComfyUI leverages the [VueUse](https://vueuse.org/) library, which provides a collection of essential Vue Composition API utilities. Instead of implementing common functionality from scratch, prefer using VueUse composables for:
|
||||
|
||||
- DOM event handling (`useEventListener`, `useMouseInElement`)
|
||||
- Element measurements (`useElementBounding`, `useElementSize`)
|
||||
- Asynchronous operations (`useAsyncState`, `useFetch`)
|
||||
- Animation and timing (`useTransition`, `useTimeout`, `useInterval`)
|
||||
- Browser APIs (`useLocalStorage`, `useClipboard`)
|
||||
- Sensors (`useDeviceMotion`, `useDeviceOrientation`)
|
||||
- State management (`createGlobalState`, `useStorage`)
|
||||
- ...and [more](https://vueuse.org/functions.html)
|
||||
|
||||
Examples:
|
||||
|
||||
```js
|
||||
// Instead of manually adding/removing event listeners
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
useEventListener(window, 'resize', handleResize)
|
||||
|
||||
// Instead of manually tracking element measurements
|
||||
import { useElementBounding } from '@vueuse/core'
|
||||
|
||||
const { width, height, top, left } = useElementBounding(elementRef)
|
||||
|
||||
// Instead of manual async state management
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
|
||||
const { state, isReady, isLoading } = useAsyncState(
|
||||
fetch('https://api.example.com/data').then(r => r.json()),
|
||||
{ data: [] }
|
||||
)
|
||||
```
|
||||
|
||||
For a complete list of available functions, see the [VueUse documentation](https://vueuse.org/functions.html).
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
When creating or modifying composables, follow these best practices:
|
||||
|
||||
1. **Name with `use` prefix**: All composables should start with "use"
|
||||
2. **Return an object**: Composables should return an object with named properties/methods
|
||||
3. **Handle cleanup**: Use `onUnmounted` to clean up resources
|
||||
4. **Document parameters and return values**: Add JSDoc comments
|
||||
5. **Test composables**: Write unit tests for composable functionality
|
||||
6. **Use VueUse**: Leverage VueUse composables instead of reimplementing common functionality
|
||||
7. **Implement proper cleanup**: Cancel debounced functions, pending requests, and clear maps
|
||||
8. **Use watchDebounced/watchThrottled**: For performance-sensitive reactive operations
|
||||
|
||||
### Composable Template
|
||||
|
||||
Here's a template for creating a new composable:
|
||||
|
||||
```typescript
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
/**
|
||||
* Composable for [functionality description]
|
||||
* @param options Configuration options
|
||||
* @returns Object containing state and methods
|
||||
*/
|
||||
export function useExample(options = {}) {
|
||||
// State
|
||||
const state = ref({
|
||||
// Initial state
|
||||
});
|
||||
|
||||
// Computed values
|
||||
const derivedValue = computed(() => {
|
||||
// Compute from state
|
||||
return state.value.someProperty;
|
||||
});
|
||||
|
||||
// Methods
|
||||
function doSomething() {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
// Setup
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Cleanup
|
||||
});
|
||||
|
||||
// Return exposed state and methods
|
||||
return {
|
||||
state,
|
||||
derivedValue,
|
||||
doSomething
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Composables in ComfyUI frequently use these patterns:
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
export function useState() {
|
||||
const count = ref(0);
|
||||
|
||||
function increment() {
|
||||
count.value++;
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
increment
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Event Handling with VueUse
|
||||
|
||||
```typescript
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
export function useKeyPress(key) {
|
||||
const isPressed = ref(false);
|
||||
|
||||
useEventListener('keydown', (e) => {
|
||||
if (e.key === key) {
|
||||
isPressed.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener('keyup', (e) => {
|
||||
if (e.key === key) {
|
||||
isPressed.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isPressed };
|
||||
}
|
||||
```
|
||||
|
||||
### Fetch & Load with VueUse
|
||||
|
||||
```typescript
|
||||
import { useAsyncState } from '@vueuse/core';
|
||||
|
||||
export function useFetchData(url) {
|
||||
const { state: data, isLoading, error, execute: refresh } = useAsyncState(
|
||||
async () => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch data');
|
||||
return response.json();
|
||||
},
|
||||
null,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { data, isLoading, error, refresh };
|
||||
}
|
||||
```
|
||||
|
||||
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
@@ -11,11 +12,13 @@ import { usdToMicros } from '@/utils/formatUtil'
|
||||
* All actions are wrapped with error handling.
|
||||
* @returns {Object} - Object containing all Firebase Auth actions
|
||||
*/
|
||||
export const useFirebaseAuthService = () => {
|
||||
export const useFirebaseAuthActions = () => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
|
||||
|
||||
const accessError = ref(false)
|
||||
|
||||
const reportError = (error: unknown) => {
|
||||
// Ref: https://firebase.google.com/docs/auth/admin/errors
|
||||
if (
|
||||
@@ -26,6 +29,7 @@ export const useFirebaseAuthService = () => {
|
||||
'auth/unauthorized-continue-uri'
|
||||
].includes(error.code)
|
||||
) {
|
||||
accessError.value = true
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
@@ -141,6 +145,7 @@ export const useFirebaseAuthService = () => {
|
||||
signInWithGithub,
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
updatePassword
|
||||
updatePassword,
|
||||
accessError
|
||||
}
|
||||
}
|
||||
122
src/composables/node/useNodeBadge.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
59
src/composables/node/useNodeChatHistory.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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 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) {
|
||||
// First remove any existing widget
|
||||
removeChatHistory(node)
|
||||
|
||||
// Then add the widget with new history
|
||||
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
|
||||
}
|
||||
}
|
||||
53
src/composables/useBrowserTabTitle.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const DEFAULT_TITLE = 'ComfyUI'
|
||||
const TITLE_SUFFIX = ' - ComfyUI'
|
||||
|
||||
export const useBrowserTabTitle = () => {
|
||||
const executionStore = useExecutionStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const executionText = computed(() =>
|
||||
executionStore.isIdle
|
||||
? ''
|
||||
: `[${Math.round(executionStore.executionProgress * 100)}%]`
|
||||
)
|
||||
|
||||
const newMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
const isUnsavedText = computed(() =>
|
||||
workflowStore.activeWorkflow?.isModified ||
|
||||
!workflowStore.activeWorkflow?.isPersisted
|
||||
? ' *'
|
||||
: ''
|
||||
)
|
||||
const workflowNameText = computed(() => {
|
||||
const workflowName = workflowStore.activeWorkflow?.filename
|
||||
return workflowName
|
||||
? isUnsavedText.value + workflowName + TITLE_SUFFIX
|
||||
: DEFAULT_TITLE
|
||||
})
|
||||
|
||||
const nodeExecutionTitle = computed(() =>
|
||||
executionStore.executingNode && executionStore.executingNodeProgress
|
||||
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const workflowTitle = computed(
|
||||
() =>
|
||||
executionText.value +
|
||||
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
|
||||
)
|
||||
|
||||
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
|
||||
useTitle(title)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
LiteGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import {
|
||||
DEFAULT_DARK_COLOR_PALETTE,
|
||||
DEFAULT_LIGHT_COLOR_PALETTE
|
||||
@@ -13,7 +14,6 @@ import { t } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
@@ -32,7 +32,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const firebaseAuthService = useFirebaseAuthService()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const toastStore = useToastStore()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
@@ -320,7 +320,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
const queueNodeIds = getSelectedNodes()
|
||||
.filter((node) => node.constructor.nodeData.output_node)
|
||||
.filter((node) => node.constructor.nodeData?.output_node)
|
||||
.map((node) => node.id)
|
||||
if (queueNodeIds.length === 0) {
|
||||
toastStore.add({
|
||||
@@ -671,7 +671,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Sign Out',
|
||||
versionAdded: '1.18.1',
|
||||
function: async () => {
|
||||
await firebaseAuthService.logout()
|
||||
await firebaseAuthActions.logout()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -128,4 +128,10 @@ export const useLitegraphSettings = () => {
|
||||
'LiteGraph.Pointer.TrackpadGestures'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.saveViewportWithGraph = settingStore.get(
|
||||
'Comfy.EnableWorkflowViewRestore'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
23
src/composables/useProgressFavicon.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useFavicon } from '@vueuse/core'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
export const useProgressFavicon = () => {
|
||||
const defaultFavicon = '/assets/images/favicon_progress_16x16/frame_9.png'
|
||||
const favicon = useFavicon(defaultFavicon)
|
||||
const executionStore = useExecutionStore()
|
||||
const totalFrames = 10
|
||||
|
||||
watch(
|
||||
[() => executionStore.executionProgress, () => executionStore.isIdle],
|
||||
([progress, isIdle]) => {
|
||||
if (isIdle) {
|
||||
favicon.value = defaultFavicon
|
||||
} else {
|
||||
const frame = Math.floor(progress * totalFrames)
|
||||
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import type { Hit } from 'algoliasearch/dist/lite/browser'
|
||||
import { memoize, orderBy } from 'lodash'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
AlgoliaNodePack,
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
|
||||
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||
|
||||
const SEARCH_DEBOUNCE_TIME = 256
|
||||
const SEARCH_DEBOUNCE_TIME = 320
|
||||
const DEFAULT_PAGE_SIZE = 64
|
||||
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
|
||||
|
||||
const DEFAULT_MAX_CACHE_SIZE = 64
|
||||
const SORT_DIRECTIONS: Record<SortableAlgoliaField, 'asc' | 'desc'> = {
|
||||
[SortableAlgoliaField.Downloads]: 'desc',
|
||||
[SortableAlgoliaField.Created]: 'desc',
|
||||
@@ -30,7 +30,12 @@ const isDateField = (field: SortableAlgoliaField): boolean =>
|
||||
/**
|
||||
* Composable for managing UI state of Comfy Node Registry search.
|
||||
*/
|
||||
export function useRegistrySearch() {
|
||||
export function useRegistrySearch(
|
||||
options: {
|
||||
maxCacheSize?: number
|
||||
} = {}
|
||||
) {
|
||||
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
|
||||
const isLoading = ref(false)
|
||||
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
|
||||
const searchMode = ref<'nodes' | 'packs'>('packs')
|
||||
@@ -56,7 +61,10 @@ export function useRegistrySearch() {
|
||||
: []
|
||||
)
|
||||
|
||||
const { searchPacks, toRegistryPack } = useAlgoliaSearchService()
|
||||
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
|
||||
useAlgoliaSearchService({
|
||||
maxCacheSize
|
||||
})
|
||||
|
||||
const algoliaToRegistry = memoize(
|
||||
toRegistryPack,
|
||||
@@ -77,7 +85,7 @@ export function useRegistrySearch() {
|
||||
if (!options.append) {
|
||||
pageNumber.value = 0
|
||||
}
|
||||
const { nodePacks, querySuggestions } = await searchPacks(
|
||||
const { nodePacks, querySuggestions } = await searchPacksCached(
|
||||
searchQuery.value,
|
||||
{
|
||||
pageSize: pageSize.value,
|
||||
@@ -116,6 +124,8 @@ export function useRegistrySearch() {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
onUnmounted(clearSearchPacksCache)
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
pageNumber,
|
||||
|
||||
43
src/composables/widgets/useChatHistoryWidget.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useChatHistoryWidget = (
|
||||
options: {
|
||||
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
|
||||
} = {}
|
||||
) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
const widgetValue = ref<string>('')
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
InstanceType<typeof ChatHistoryWidget>['$props']
|
||||
>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: ChatHistoryWidget,
|
||||
props: options.props,
|
||||
inputSpec,
|
||||
options: {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => 400 + PADDING
|
||||
}
|
||||
})
|
||||
addWidget(node, widget)
|
||||
return widget
|
||||
}
|
||||
|
||||
return widgetConstructor
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
|
||||
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
|
||||
|
||||
@@ -16,7 +17,7 @@ const renderPreview = (
|
||||
node: LGraphNode,
|
||||
shiftY: number
|
||||
) => {
|
||||
const canvas = app.canvas
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
const mouse = canvas.graph_mouse
|
||||
|
||||
if (!canvas.pointer_is_down && node.pointerDown) {
|
||||
|
||||
@@ -8,7 +8,11 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useTextPreviewWidget = () => {
|
||||
export const useTextPreviewWidget = (
|
||||
options: {
|
||||
minHeight?: number
|
||||
} = {}
|
||||
) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
@@ -24,7 +28,7 @@ export const useTextPreviewWidget = () => {
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => 42 + PADDING
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING
|
||||
}
|
||||
})
|
||||
addWidget(node, widget)
|
||||
|
||||
139
src/extensions/core/README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Core Extensions
|
||||
|
||||
This directory contains the core extensions that provide essential functionality to the ComfyUI frontend.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Extension Architecture](#extension-architecture)
|
||||
- [Core Extensions](#core-extensions)
|
||||
- [Extension Development](#extension-development)
|
||||
- [Extension Hooks](#extension-hooks)
|
||||
- [Further Reading](#further-reading)
|
||||
|
||||
## Overview
|
||||
|
||||
Extensions in ComfyUI are modular JavaScript modules that extend and enhance the functionality of the frontend. The extensions in this directory are considered "core" as they provide fundamental features that are built into ComfyUI by default.
|
||||
|
||||
## Extension Architecture
|
||||
|
||||
ComfyUI's extension system follows these key principles:
|
||||
|
||||
1. **Registration-based:** Extensions must register themselves with the application using `app.registerExtension()`
|
||||
2. **Hook-driven:** Extensions interact with the system through predefined hooks
|
||||
3. **Non-intrusive:** Extensions should avoid directly modifying core objects where possible
|
||||
|
||||
## Core Extensions List
|
||||
|
||||
The core extensions include:
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| clipspace.ts | Implements the Clipspace feature for temporary image storage |
|
||||
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities |
|
||||
| groupNode.ts | Implements the group node functionality to organize workflows |
|
||||
| load3d.ts | Supports 3D model loading and visualization |
|
||||
| maskeditor.ts | Implements the mask editor for image masking operations |
|
||||
| noteNode.ts | Adds note nodes for documentation within workflows |
|
||||
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections |
|
||||
| uploadImage.ts | Handles image upload functionality |
|
||||
| webcamCapture.ts | Provides webcam capture capabilities |
|
||||
| widgetInputs.ts | Implements various widget input types |
|
||||
|
||||
## Extension Development
|
||||
|
||||
When developing or modifying extensions, follow these best practices:
|
||||
|
||||
1. **Use provided hooks** rather than directly modifying core application objects
|
||||
2. **Maintain compatibility** with other extensions
|
||||
3. **Follow naming conventions** for both extension names and settings
|
||||
4. **Properly document** extension hooks and functionality
|
||||
5. **Test with other extensions** to ensure no conflicts
|
||||
|
||||
### Extension Registration
|
||||
|
||||
Extensions are registered using the `app.registerExtension()` method:
|
||||
|
||||
```javascript
|
||||
app.registerExtension({
|
||||
name: "MyExtension",
|
||||
|
||||
// Hook implementations
|
||||
async init() {
|
||||
// Implementation
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
// Other hooks as needed
|
||||
});
|
||||
```
|
||||
|
||||
## Extension Hooks
|
||||
|
||||
ComfyUI extensions can implement various hooks that are called at specific points in the application lifecycle:
|
||||
|
||||
### Hook Execution Sequence
|
||||
|
||||
#### Web Page Load
|
||||
|
||||
```
|
||||
init
|
||||
addCustomNodeDefs
|
||||
getCustomWidgets
|
||||
beforeRegisterNodeDef [repeated multiple times]
|
||||
registerCustomNodes
|
||||
beforeConfigureGraph
|
||||
nodeCreated
|
||||
loadedGraphNode
|
||||
afterConfigureGraph
|
||||
setup
|
||||
```
|
||||
|
||||
#### Loading Workflow
|
||||
|
||||
```
|
||||
beforeConfigureGraph
|
||||
beforeRegisterNodeDef [zero, one, or multiple times]
|
||||
nodeCreated [repeated multiple times]
|
||||
loadedGraphNode [repeated multiple times]
|
||||
afterConfigureGraph
|
||||
```
|
||||
|
||||
#### Adding New Node
|
||||
|
||||
```
|
||||
nodeCreated
|
||||
```
|
||||
|
||||
### Key Hooks
|
||||
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `init` | Called after canvas creation but before nodes are added |
|
||||
| `setup` | Called after the application is fully set up and running |
|
||||
| `addCustomNodeDefs` | Called before nodes are registered with the graph |
|
||||
| `getCustomWidgets` | Allows extensions to add custom widgets |
|
||||
| `beforeRegisterNodeDef` | Allows extensions to modify nodes before registration |
|
||||
| `registerCustomNodes` | Allows extensions to register additional nodes |
|
||||
| `loadedGraphNode` | Called when a node is reloaded onto the graph |
|
||||
| `nodeCreated` | Called after a node's constructor |
|
||||
| `beforeConfigureGraph` | Called before a graph is configured |
|
||||
| `afterConfigureGraph` | Called after a graph is configured |
|
||||
| `getSelectionToolboxCommands` | Allows extensions to add commands to the selection toolbox |
|
||||
|
||||
For the complete list of available hooks and detailed descriptions, see the [ComfyExtension interface in comfy.ts](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/types/comfy.ts).
|
||||
|
||||
## Further Reading
|
||||
|
||||
For more detailed information about ComfyUI's extension system, refer to the official documentation:
|
||||
|
||||
- [JavaScript Extension Overview](https://docs.comfy.org/custom-nodes/js/javascript_overview)
|
||||
- [JavaScript Hooks](https://docs.comfy.org/custom-nodes/js/javascript_hooks)
|
||||
- [JavaScript Objects and Hijacking](https://docs.comfy.org/custom-nodes/js/javascript_objects_and_hijacking)
|
||||
- [JavaScript Settings](https://docs.comfy.org/custom-nodes/js/javascript_settings)
|
||||
- [JavaScript Examples](https://docs.comfy.org/custom-nodes/js/javascript_examples)
|
||||
|
||||
Also, check the main [README.md](https://github.com/Comfy-Org/ComfyUI_frontend#developer-apis) section on Developer APIs for the latest information on extension APIs and features.
|
||||
@@ -797,7 +797,7 @@ export class GroupNodeConfig {
|
||||
|
||||
export class GroupNodeHandler {
|
||||
node: LGraphNode
|
||||
groupData
|
||||
groupData: any
|
||||
innerNodes: any
|
||||
|
||||
constructor(node: LGraphNode) {
|
||||
|
||||
@@ -82,6 +82,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
}
|
||||
|
||||
async setBackgroundImage(uploadPath: string): Promise<void> {
|
||||
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
|
||||
|
||||
if (uploadPath === '') {
|
||||
this.removeBackgroundImage()
|
||||
return
|
||||
@@ -123,7 +125,9 @@ export class SceneManager implements SceneManagerInterface {
|
||||
)
|
||||
|
||||
this.eventManager.emitEvent('backgroundImageChange', uploadPath)
|
||||
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
|
||||
} catch (error) {
|
||||
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
|
||||
console.error('Error loading background image:', error)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +143,7 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.backgroundTexture.dispose()
|
||||
this.backgroundTexture = null
|
||||
}
|
||||
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
|
||||
}
|
||||
|
||||
updateBackgroundSize(
|
||||
|
||||
@@ -401,6 +401,7 @@ app.registerExtension({
|
||||
// @ts-expect-error
|
||||
data.groupNodes = {}
|
||||
}
|
||||
if (nodeData == null) throw new TypeError('nodeData is not set')
|
||||
// @ts-expect-error
|
||||
data.groupNodes[nodeData.name] = groupData
|
||||
// @ts-expect-error
|
||||
|
||||
@@ -3,6 +3,7 @@ Preview Any - original implement from
|
||||
https://github.com/rgthree/rgthree-comfy/blob/main/py/display_any.py
|
||||
upstream requested in https://github.com/Kosinkadink/rfcs/blob/main/rfcs/0000-corenodes.md#preview-nodes
|
||||
*/
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidget } from '@/scripts/domWidget'
|
||||
import { ComfyWidgets } from '@/scripts/widgets'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
@@ -117,7 +117,10 @@ app.registerExtension({
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.serialize = false
|
||||
|
||||
const isOutputNode = node.constructor.nodeData.output_node
|
||||
const { nodeData } = node.constructor
|
||||
if (nodeData == null) throw new TypeError('nodeData is null')
|
||||
|
||||
const isOutputNode = nodeData.output_node
|
||||
if (isOutputNode) {
|
||||
// Hide the audio widget when there is no audio initially.
|
||||
audioUIWidget.element.classList.add('empty-audio-widget')
|
||||
|
||||
@@ -114,7 +114,9 @@
|
||||
"learnMore": "Learn more",
|
||||
"amount": "Amount",
|
||||
"unknownError": "Unknown error",
|
||||
"title": "Title"
|
||||
"title": "Title",
|
||||
"edit": "Edit",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
@@ -1259,7 +1261,9 @@
|
||||
"failedToInitiateCreditPurchase": "Failed to initiate credit purchase: {error}",
|
||||
"failedToAccessBillingPortal": "Failed to access billing portal: {error}",
|
||||
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
|
||||
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist."
|
||||
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
|
||||
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
|
||||
"nothingSelected": "Nothing selected"
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
@@ -1380,30 +1384,17 @@
|
||||
"notSet": "Not set",
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"apiNodesNews": {
|
||||
"introducing": "Introducing",
|
||||
"subtitle": "Access all the popular paid models natively in ComfyUI",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"title": "Login/Create an account:",
|
||||
"subtitle": "Settings > User > Login"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Purchase credits:",
|
||||
"subtitle": "Settings > Credits > Buy Credits"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Locate new API Nodes under 'API Node' section and add to the canvas"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Run!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
|
||||
"disabledTooltip": "No output nodes selected"
|
||||
}
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "Cancel",
|
||||
"editTooltip": "Edit message",
|
||||
"cancelEditTooltip": "Cancel edit",
|
||||
"copiedTooltip": "Copied",
|
||||
"copyTooltip": "Copy message to clipboard"
|
||||
}
|
||||
}
|
||||
@@ -4,26 +4,6 @@
|
||||
"title": "Nodo(s) de API",
|
||||
"totalCost": "Costo total"
|
||||
},
|
||||
"apiNodesNews": {
|
||||
"introducing": "Presentamos",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"subtitle": "Configuración > Usuario > Iniciar sesión",
|
||||
"title": "Inicia sesión/Crea una cuenta:"
|
||||
},
|
||||
"step2": {
|
||||
"subtitle": "Configuración > Créditos > Comprar créditos",
|
||||
"title": "Compra créditos:"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Ubica los nuevos nodos API en la sección 'API Node' y agrégalos al lienzo"
|
||||
},
|
||||
"step4": {
|
||||
"title": "¡Ejecuta!"
|
||||
}
|
||||
},
|
||||
"subtitle": "Todos los modelos externos ahora disponibles en ComfyUI"
|
||||
},
|
||||
"apiNodesSignInDialog": {
|
||||
"message": "Este flujo de trabajo contiene nodos de API, que requieren que inicies sesión en tu cuenta para poder ejecutar.",
|
||||
"title": "Se requiere iniciar sesión para usar los nodos de API"
|
||||
@@ -102,6 +82,13 @@
|
||||
"title": "Crea una cuenta"
|
||||
}
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "Cancelar",
|
||||
"cancelEditTooltip": "Cancelar edición",
|
||||
"copiedTooltip": "Copiado",
|
||||
"copyTooltip": "Copiar mensaje al portapapeles",
|
||||
"editTooltip": "Editar mensaje"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Error al copiar al portapapeles",
|
||||
"errorNotSupported": "API del portapapeles no soportada en su navegador",
|
||||
@@ -277,6 +264,7 @@
|
||||
"continue": "Continuar",
|
||||
"control_after_generate": "control después de generar",
|
||||
"control_before_generate": "control antes de generar",
|
||||
"copy": "Copiar",
|
||||
"copyToClipboard": "Copiar al portapapeles",
|
||||
"currentUser": "Usuario actual",
|
||||
"customize": "Personalizar",
|
||||
@@ -288,6 +276,7 @@
|
||||
"disableAll": "Deshabilitar todo",
|
||||
"disabling": "Deshabilitando",
|
||||
"download": "Descargar",
|
||||
"edit": "Editar",
|
||||
"empty": "Vacío",
|
||||
"enableAll": "Habilitar todo",
|
||||
"enabled": "Habilitado",
|
||||
@@ -1354,6 +1343,7 @@
|
||||
"no3dSceneToExport": "No hay escena 3D para exportar",
|
||||
"noTemplatesToExport": "No hay plantillas para exportar",
|
||||
"nodeDefinitionsUpdated": "Definiciones de nodos actualizadas",
|
||||
"nothingSelected": "Nada seleccionado",
|
||||
"nothingToGroup": "Nada para agrupar",
|
||||
"nothingToQueue": "Nada para poner en cola",
|
||||
"pendingTasksDeleted": "Tareas pendientes eliminadas",
|
||||
@@ -1362,6 +1352,7 @@
|
||||
"unableToGetModelFilePath": "No se puede obtener la ruta del archivo del modelo",
|
||||
"unauthorizedDomain": "Tu dominio {domain} no está autorizado para usar este servicio. Por favor, contacta a {email} para agregar tu dominio a la lista blanca.",
|
||||
"updateRequested": "Actualización solicitada",
|
||||
"useApiKeyTip": "Consejo: ¿No puedes acceder al inicio de sesión normal? Usa la opción de clave API de Comfy.",
|
||||
"userNotAuthenticated": "Usuario no autenticado"
|
||||
},
|
||||
"userSelect": {
|
||||
|
||||
@@ -4,26 +4,6 @@
|
||||
"title": "Nœud(s) API",
|
||||
"totalCost": "Coût total"
|
||||
},
|
||||
"apiNodesNews": {
|
||||
"introducing": "Présentation",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"subtitle": "Paramètres > Utilisateur > Connexion",
|
||||
"title": "Connectez-vous / Créez un compte :"
|
||||
},
|
||||
"step2": {
|
||||
"subtitle": "Paramètres > Crédits > Acheter des crédits",
|
||||
"title": "Achetez des crédits :"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Trouvez les nouveaux nœuds API dans la section 'API Node' et ajoutez-les à la toile"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Lancez !"
|
||||
}
|
||||
},
|
||||
"subtitle": "Tous les modèles externes sont désormais disponibles dans ComfyUI"
|
||||
},
|
||||
"apiNodesSignInDialog": {
|
||||
"message": "Ce flux de travail contient des nœuds API, qui nécessitent que vous soyez connecté à votre compte pour pouvoir fonctionner.",
|
||||
"title": "Connexion requise pour utiliser les nœuds API"
|
||||
@@ -102,6 +82,13 @@
|
||||
"title": "Créer un compte"
|
||||
}
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "Annuler",
|
||||
"cancelEditTooltip": "Annuler la modification",
|
||||
"copiedTooltip": "Copié",
|
||||
"copyTooltip": "Copier le message dans le presse-papiers",
|
||||
"editTooltip": "Modifier le message"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Échec de la copie dans le presse-papiers",
|
||||
"errorNotSupported": "L'API du presse-papiers n'est pas prise en charge par votre navigateur",
|
||||
@@ -277,6 +264,7 @@
|
||||
"continue": "Continuer",
|
||||
"control_after_generate": "contrôle après génération",
|
||||
"control_before_generate": "contrôle avant génération",
|
||||
"copy": "Copier",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"currentUser": "Utilisateur actuel",
|
||||
"customize": "Personnaliser",
|
||||
@@ -288,6 +276,7 @@
|
||||
"disableAll": "Désactiver tout",
|
||||
"disabling": "Désactivation",
|
||||
"download": "Télécharger",
|
||||
"edit": "Modifier",
|
||||
"empty": "Vide",
|
||||
"enableAll": "Activer tout",
|
||||
"enabled": "Activé",
|
||||
@@ -1354,6 +1343,7 @@
|
||||
"no3dSceneToExport": "Aucune scène 3D à exporter",
|
||||
"noTemplatesToExport": "Aucun modèle à exporter",
|
||||
"nodeDefinitionsUpdated": "Définitions de nœuds mises à jour",
|
||||
"nothingSelected": "Aucune sélection",
|
||||
"nothingToGroup": "Rien à regrouper",
|
||||
"nothingToQueue": "Rien à ajouter à la file d’attente",
|
||||
"pendingTasksDeleted": "Tâches en attente supprimées",
|
||||
@@ -1362,6 +1352,7 @@
|
||||
"unableToGetModelFilePath": "Impossible d'obtenir le chemin du fichier modèle",
|
||||
"unauthorizedDomain": "Votre domaine {domain} n'est pas autorisé à utiliser ce service. Veuillez contacter {email} pour ajouter votre domaine à la liste blanche.",
|
||||
"updateRequested": "Mise à jour demandée",
|
||||
"useApiKeyTip": "Astuce : Vous ne pouvez pas accéder à la connexion normale ? Utilisez l’option Clé API Comfy.",
|
||||
"userNotAuthenticated": "Utilisateur non authentifié"
|
||||
},
|
||||
"userSelect": {
|
||||
|
||||
@@ -4,26 +4,6 @@
|
||||
"title": "APIノード",
|
||||
"totalCost": "合計コスト"
|
||||
},
|
||||
"apiNodesNews": {
|
||||
"introducing": "紹介",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"subtitle": "設定 > ユーザー > ログイン",
|
||||
"title": "ログイン/アカウント作成:"
|
||||
},
|
||||
"step2": {
|
||||
"subtitle": "設定 > クレジット > クレジットを購入",
|
||||
"title": "クレジットを購入:"
|
||||
},
|
||||
"step3": {
|
||||
"title": "「APIノード」セクションで新しいAPIノードを見つけてキャンバスに追加"
|
||||
},
|
||||
"step4": {
|
||||
"title": "実行!"
|
||||
}
|
||||
},
|
||||
"subtitle": "すべての外部モデルがComfyUIで利用可能になりました"
|
||||
},
|
||||
"apiNodesSignInDialog": {
|
||||
"message": "このワークフローにはAPIノードが含まれており、実行するためにはアカウントにサインインする必要があります。",
|
||||
"title": "APIノードを使用するためにはサインインが必要です"
|
||||
@@ -102,6 +82,13 @@
|
||||
"title": "アカウントを作成する"
|
||||
}
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "キャンセル",
|
||||
"cancelEditTooltip": "編集をキャンセル",
|
||||
"copiedTooltip": "コピーしました",
|
||||
"copyTooltip": "メッセージをクリップボードにコピー",
|
||||
"editTooltip": "メッセージを編集"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "クリップボードへのコピーに失敗しました",
|
||||
"errorNotSupported": "お使いのブラウザではクリップボードAPIがサポートされていません",
|
||||
@@ -277,6 +264,7 @@
|
||||
"continue": "続ける",
|
||||
"control_after_generate": "生成後の制御",
|
||||
"control_before_generate": "生成前の制御",
|
||||
"copy": "コピー",
|
||||
"copyToClipboard": "クリップボードにコピー",
|
||||
"currentUser": "現在のユーザー",
|
||||
"customize": "カスタマイズ",
|
||||
@@ -288,6 +276,7 @@
|
||||
"disableAll": "すべて無効にする",
|
||||
"disabling": "無効化",
|
||||
"download": "ダウンロード",
|
||||
"edit": "編集",
|
||||
"empty": "空",
|
||||
"enableAll": "すべて有効にする",
|
||||
"enabled": "有効",
|
||||
@@ -1354,6 +1343,7 @@
|
||||
"no3dSceneToExport": "エクスポートする3Dシーンがありません",
|
||||
"noTemplatesToExport": "エクスポートするテンプレートがありません",
|
||||
"nodeDefinitionsUpdated": "ノード定義が更新されました",
|
||||
"nothingSelected": "選択されていません",
|
||||
"nothingToGroup": "グループ化するものがありません",
|
||||
"nothingToQueue": "キューに追加する項目がありません",
|
||||
"pendingTasksDeleted": "保留中のタスクが削除されました",
|
||||
@@ -1362,6 +1352,7 @@
|
||||
"unableToGetModelFilePath": "モデルファイルのパスを取得できません",
|
||||
"unauthorizedDomain": "あなたのドメイン {domain} はこのサービスを利用する権限がありません。ご利用のドメインをホワイトリストに追加するには、{email} までご連絡ください。",
|
||||
"updateRequested": "更新が要求されました",
|
||||
"useApiKeyTip": "ヒント:通常のログインにアクセスできませんか?Comfy APIキーオプションを使用してください。",
|
||||
"userNotAuthenticated": "ユーザーが認証されていません"
|
||||
},
|
||||
"userSelect": {
|
||||
|
||||
@@ -4,26 +4,6 @@
|
||||
"title": "API 노드(들)",
|
||||
"totalCost": "총 비용"
|
||||
},
|
||||
"apiNodesNews": {
|
||||
"introducing": "소개합니다",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"subtitle": "설정 > 사용자 > 로그인",
|
||||
"title": "로그인/계정 생성:"
|
||||
},
|
||||
"step2": {
|
||||
"subtitle": "설정 > 크레딧 > 크레딧 구매",
|
||||
"title": "크레딧 구매:"
|
||||
},
|
||||
"step3": {
|
||||
"title": "'API Node' 섹션에서 새로운 API 노드를 찾아 캔버스에 추가하세요"
|
||||
},
|
||||
"step4": {
|
||||
"title": "실행!"
|
||||
}
|
||||
},
|
||||
"subtitle": "모든 외부 모델이 이제 ComfyUI에서 사용 가능합니다"
|
||||
},
|
||||
"apiNodesSignInDialog": {
|
||||
"message": "이 워크플로우에는 API 노드가 포함되어 있으며, 실행하려면 계정에 로그인해야 합니다.",
|
||||
"title": "API 노드 사용에 필요한 로그인"
|
||||
@@ -102,6 +82,13 @@
|
||||
"title": "계정 생성"
|
||||
}
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "취소",
|
||||
"cancelEditTooltip": "편집 취소",
|
||||
"copiedTooltip": "복사됨",
|
||||
"copyTooltip": "메시지를 클립보드에 복사",
|
||||
"editTooltip": "메시지 편집"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "클립보드에 복사하지 못했습니다",
|
||||
"errorNotSupported": "브라우저가 클립보드 API를 지원하지 않습니다.",
|
||||
@@ -277,6 +264,7 @@
|
||||
"continue": "계속",
|
||||
"control_after_generate": "생성 후 제어",
|
||||
"control_before_generate": "생성 전 제어",
|
||||
"copy": "복사",
|
||||
"copyToClipboard": "클립보드에 복사",
|
||||
"currentUser": "현재 사용자",
|
||||
"customize": "사용자 정의",
|
||||
@@ -288,6 +276,7 @@
|
||||
"disableAll": "모두 비활성화",
|
||||
"disabling": "비활성화 중",
|
||||
"download": "다운로드",
|
||||
"edit": "편집",
|
||||
"empty": "비어 있음",
|
||||
"enableAll": "모두 활성화",
|
||||
"enabled": "활성화됨",
|
||||
@@ -655,20 +644,20 @@
|
||||
"Tolerance": "허용 오차"
|
||||
},
|
||||
"menu": {
|
||||
"autoQueue": "자동 실행 큐",
|
||||
"autoQueue": "자동 실행 대기열",
|
||||
"batchCount": "배치 수",
|
||||
"batchCountTooltip": "워크플로 작업을 실행 큐에 반복 추가할 횟수",
|
||||
"batchCountTooltip": "워크플로 작업을 실행 대기열에 반복 추가할 횟수",
|
||||
"clear": "워크플로 비우기",
|
||||
"clipspace": "클립스페이스 열기",
|
||||
"disabled": "비활성화됨",
|
||||
"disabledTooltip": "워크플로 작업을 자동으로 실행 큐에 추가하지 않습니다.",
|
||||
"disabledTooltip": "워크플로 작업을 자동으로 실행 대기열에 추가하지 않습니다.",
|
||||
"execute": "실행",
|
||||
"hideMenu": "메뉴 숨기기",
|
||||
"instant": "즉시",
|
||||
"instantTooltip": "워크플로 실행이 완료되면 즉시 실행 큐에 추가합니다.",
|
||||
"instantTooltip": "워크플로 실행이 완료되면 즉시 실행 대기열에 추가합니다.",
|
||||
"interrupt": "현재 실행 취소",
|
||||
"onChange": "변경 시",
|
||||
"onChangeTooltip": "변경이 있는 경우에만 워크플로를 실행 큐에 추가합니다.",
|
||||
"onChangeTooltip": "변경이 있는 경우에만 워크플로를 실행 대기열에 추가합니다.",
|
||||
"refresh": "노드 정의 새로 고침",
|
||||
"resetView": "캔버스 보기 재설정",
|
||||
"run": "실행",
|
||||
@@ -725,8 +714,8 @@
|
||||
"Pin/Unpin Selected Items": "선택한 항목 고정/고정 해제",
|
||||
"Pin/Unpin Selected Nodes": "선택한 노드 고정/고정 해제",
|
||||
"Previous Opened Workflow": "이전 열린 워크플로",
|
||||
"Queue Prompt": "실행 큐에 프롬프트 추가",
|
||||
"Queue Prompt (Front)": "실행 큐 맨 앞에 프롬프트 추가",
|
||||
"Queue Prompt": "실행 대기열에 프롬프트 추가",
|
||||
"Queue Prompt (Front)": "실행 대기열 맨 앞에 프롬프트 추가",
|
||||
"Queue Selected Output Nodes": "선택한 출력 노드 대기열에 추가",
|
||||
"Quit": "종료",
|
||||
"Redo": "다시 실행",
|
||||
@@ -745,7 +734,7 @@
|
||||
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
|
||||
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
|
||||
"Toggle Progress Dialog": "진행 상황 대화 상자 전환",
|
||||
"Toggle Queue Sidebar": "실행 큐 사이드바 전환",
|
||||
"Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
|
||||
"Toggle Search Box": "검색 상자 전환",
|
||||
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
|
||||
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
|
||||
@@ -816,7 +805,7 @@
|
||||
"photomaker": "포토메이커",
|
||||
"postprocessing": "후처리",
|
||||
"preprocessors": "전처리기",
|
||||
"primitive": "프리미티브",
|
||||
"primitive": "기본 입력",
|
||||
"samplers": "샘플러",
|
||||
"sampling": "샘플링",
|
||||
"schedulers": "스케줄러",
|
||||
@@ -1041,8 +1030,8 @@
|
||||
"Node Widget": "노드 위젯",
|
||||
"NodeLibrary": "노드 라이브러리",
|
||||
"Pointer": "포인터",
|
||||
"Queue": "실행 큐",
|
||||
"QueueButton": "실행 큐 버튼",
|
||||
"Queue": "실행 대기열",
|
||||
"QueueButton": "실행 대기열 버튼",
|
||||
"Reroute": "경유점",
|
||||
"RerouteBeta": "경유점 (베타)",
|
||||
"Scene": "장면",
|
||||
@@ -1068,7 +1057,7 @@
|
||||
"sortOrder": "정렬 순서"
|
||||
},
|
||||
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
|
||||
"queue": "실행 큐",
|
||||
"queue": "실행 대기열",
|
||||
"queueTab": {
|
||||
"backToAllTasks": "모든 작업으로 돌아가기",
|
||||
"clearPendingTasks": "보류 중인 작업 지우기",
|
||||
@@ -1259,28 +1248,28 @@
|
||||
"mixing_controlnets": "여러 ControlNet 모델을 결합합니다."
|
||||
},
|
||||
"Flux": {
|
||||
"flux_canny_model_example": "에지 감지로부터 이미지를 생성합니다.",
|
||||
"flux_depth_lora_example": "깊이 인식 LoRA로 이미지를 생성합니다.",
|
||||
"flux_dev_checkpoint_example": "Flux 개발 모델로 이미지를 생성합니다.",
|
||||
"flux_canny_model_example": "검출된 경계선으로 이미지를 생성합니다.",
|
||||
"flux_depth_lora_example": "깊이 인식 LoRA 를 이용해 이미지를 생성합니다.",
|
||||
"flux_dev_checkpoint_example": "FLUX Dev 모델로 이미지를 생성합니다.",
|
||||
"flux_fill_inpaint_example": "이미지의 누락된 부분을 채웁니다.",
|
||||
"flux_fill_outpaint_example": "Flux 아웃페인팅으로 이미지를 확장합니다.",
|
||||
"flux_fill_outpaint_example": "FLUX 아웃페인팅으로 이미지를 확장합니다.",
|
||||
"flux_redux_model_example": "참조 이미지의 스타일을 가이드 이미지 생성에 적용합니다.",
|
||||
"flux_schnell": "Flux Schnell로 이미지를 빠르게 생성합니다."
|
||||
"flux_schnell": "FLUX Schnell 모델로 이미지를 빠르게 생성합니다."
|
||||
},
|
||||
"Image": {
|
||||
"hidream_e1_full": "HiDream E1로 이미지를 편집합니다.",
|
||||
"hidream_i1_dev": "HiDream I1 Dev로 이미지를 생성합니다.",
|
||||
"hidream_i1_fast": "HiDream I1로 이미지를 빠르게 생성합니다.",
|
||||
"hidream_i1_full": "HiDream I1로 이미지를 생성합니다.",
|
||||
"sd3_5_large_blur": "SD 3.5로 흐릿한 참조 이미지에서 이미지를 생성합니다.",
|
||||
"sd3_5_large_canny_controlnet_example": "SD 3.5에서 에지 감지로 이미지 생성을 가이드합니다.",
|
||||
"sd3_5_large_depth": "SD 3.5로 깊이 인식 이미지를 생성합니다.",
|
||||
"sd3_5_simple_example": "SD 3.5로 이미지를 생성합니다.",
|
||||
"hidream_e1_full": "HiDream E1 모델로 이미지를 편집합니다.",
|
||||
"hidream_i1_dev": "HiDream I1 Dev 모델로 이미지를 생성합니다.",
|
||||
"hidream_i1_fast": "HiDream I1 Fast 모델로 이미지를 빠르게 생성합니다.",
|
||||
"hidream_i1_full": "HiDream I1 Full 모델로 이미지를 생성합니다.",
|
||||
"sd3_5_large_blur": "SD 3.5 모델로 흐릿한 참조 이미지에서 이미지를 생성합니다.",
|
||||
"sd3_5_large_canny_controlnet_example": "Canny 에지 이미지를 통해 SD 3.5 모델 이미지 생성을 가이드합니다.",
|
||||
"sd3_5_large_depth": "깊이 인식 이미지를 통해 SD 3.5 모델 이미지 생성을 가이드합니다.",
|
||||
"sd3_5_simple_example": "SD 3.5 모델로 이미지를 생성합니다.",
|
||||
"sdxl_refiner_prompt_example": "SDXL 결과물을 리파이너로 향상시킵니다.",
|
||||
"sdxl_revision_text_prompts": "참조 이미지의 개념을 SDXL 이미지 생성에 적용합니다.",
|
||||
"sdxl_revision_zero_positive": "참조 이미지와 함께 텍스트 프롬프트를 추가하여 SDXL 이미지 생성을 가이드합니다.",
|
||||
"sdxl_simple_example": "SDXL로 고품질 이미지를 생성합니다.",
|
||||
"sdxlturbo_example": "SDXL Turbo로 한 번에 이미지를 생성합니다."
|
||||
"sdxl_simple_example": "SDXL 모델로 고품질 이미지를 생성합니다.",
|
||||
"sdxlturbo_example": "SDXL Turbo 모델로 1 스텝으로 이미지를 생성합니다."
|
||||
},
|
||||
"Image API": {
|
||||
"api-openai-dall-e-2-inpaint": "Dall-E 2 API로 이미지를 인페인팅합니다.",
|
||||
@@ -1354,6 +1343,7 @@
|
||||
"no3dSceneToExport": "내보낼 3D 장면이 없습니다",
|
||||
"noTemplatesToExport": "내보낼 템플릿이 없습니다",
|
||||
"nodeDefinitionsUpdated": "노드 정의가 업데이트되었습니다",
|
||||
"nothingSelected": "선택된 항목이 없습니다",
|
||||
"nothingToGroup": "그룹화할 항목이 없습니다",
|
||||
"nothingToQueue": "대기열에 추가할 항목이 없습니다",
|
||||
"pendingTasksDeleted": "보류 중인 작업이 삭제되었습니다",
|
||||
@@ -1362,6 +1352,7 @@
|
||||
"unableToGetModelFilePath": "모델 파일 경로를 가져올 수 없습니다",
|
||||
"unauthorizedDomain": "귀하의 도메인 {domain}은(는) 이 서비스를 사용할 수 있는 권한이 없습니다. 도메인을 허용 목록에 추가하려면 {email}로 문의해 주세요.",
|
||||
"updateRequested": "업데이트 요청됨",
|
||||
"useApiKeyTip": "팁: 일반 로그인을 사용할 수 없나요? Comfy API Key 옵션을 사용하세요.",
|
||||
"userNotAuthenticated": "사용자가 인증되지 않았습니다"
|
||||
},
|
||||
"userSelect": {
|
||||
|
||||
@@ -4,26 +4,6 @@
|
||||
"title": "API Node(s)",
|
||||
"totalCost": "Общая стоимость"
|
||||
},
|
||||
"apiNodesNews": {
|
||||
"introducing": "Представляем",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"subtitle": "Настройки > Пользователь > Войти",
|
||||
"title": "Войти/Создать аккаунт:"
|
||||
},
|
||||
"step2": {
|
||||
"subtitle": "Настройки > Кредиты > Купить кредиты",
|
||||
"title": "Купить кредиты:"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Найдите новые API-узлы в разделе 'API Node' и добавьте их на холст"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Запустить!"
|
||||
}
|
||||
},
|
||||
"subtitle": "Все внешние модели теперь доступны в ComfyUI"
|
||||
},
|
||||
"apiNodesSignInDialog": {
|
||||
"message": "Этот рабочий процесс содержит API Nodes, которые требуют входа в вашу учетную запись для выполнения.",
|
||||
"title": "Требуется вход для использования API Nodes"
|
||||
@@ -102,6 +82,13 @@
|
||||
"title": "Создать аккаунт"
|
||||
}
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "Отмена",
|
||||
"cancelEditTooltip": "Отменить редактирование",
|
||||
"copiedTooltip": "Скопировано",
|
||||
"copyTooltip": "Скопировать сообщение в буфер",
|
||||
"editTooltip": "Редактировать сообщение"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Не удалось скопировать в буфер обмена",
|
||||
"errorNotSupported": "API буфера обмена не поддерживается в вашем браузере",
|
||||
@@ -277,6 +264,7 @@
|
||||
"continue": "Продолжить",
|
||||
"control_after_generate": "управление после генерации",
|
||||
"control_before_generate": "управление до генерации",
|
||||
"copy": "Копировать",
|
||||
"copyToClipboard": "Скопировать в буфер обмена",
|
||||
"currentUser": "Текущий пользователь",
|
||||
"customize": "Настроить",
|
||||
@@ -288,6 +276,7 @@
|
||||
"disableAll": "Отключить все",
|
||||
"disabling": "Отключение",
|
||||
"download": "Скачать",
|
||||
"edit": "Редактировать",
|
||||
"empty": "Пусто",
|
||||
"enableAll": "Включить все",
|
||||
"enabled": "Включено",
|
||||
@@ -1354,6 +1343,7 @@
|
||||
"no3dSceneToExport": "Нет 3D сцены для экспорта",
|
||||
"noTemplatesToExport": "Нет шаблонов для экспорта",
|
||||
"nodeDefinitionsUpdated": "Определения узлов обновлены",
|
||||
"nothingSelected": "Ничего не выбрано",
|
||||
"nothingToGroup": "Нечего группировать",
|
||||
"nothingToQueue": "Нет заданий в очереди",
|
||||
"pendingTasksDeleted": "Ожидающие задачи удалены",
|
||||
@@ -1362,6 +1352,7 @@
|
||||
"unableToGetModelFilePath": "Не удалось получить путь к файлу модели",
|
||||
"unauthorizedDomain": "Ваш домен {domain} не авторизован для использования этого сервиса. Пожалуйста, свяжитесь с {email}, чтобы добавить ваш домен в белый список.",
|
||||
"updateRequested": "Запрошено обновление",
|
||||
"useApiKeyTip": "Совет: Нет доступа к обычному входу? Используйте опцию Comfy API Key.",
|
||||
"userNotAuthenticated": "Пользователь не аутентифицирован"
|
||||
},
|
||||
"userSelect": {
|
||||
|
||||
@@ -4,26 +4,6 @@
|
||||
"title": "API节点",
|
||||
"totalCost": "总成本"
|
||||
},
|
||||
"apiNodesNews": {
|
||||
"introducing": "介绍",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"subtitle": "设置 > 用户 > 登录",
|
||||
"title": "登录/创建账户:"
|
||||
},
|
||||
"step2": {
|
||||
"subtitle": "设置 > 积分 > 购买积分",
|
||||
"title": "购买积分:"
|
||||
},
|
||||
"step3": {
|
||||
"title": "在“API 节点”部分找到新的 API 节点并添加到画布"
|
||||
},
|
||||
"step4": {
|
||||
"title": "运行!"
|
||||
}
|
||||
},
|
||||
"subtitle": "所有外部模型现已在 ComfyUI 中可用"
|
||||
},
|
||||
"apiNodesSignInDialog": {
|
||||
"message": "此工作流包含API节点,需要您登录账户才能运行。",
|
||||
"title": "使用API节点需要登录"
|
||||
@@ -102,6 +82,13 @@
|
||||
"title": "创建一个账户"
|
||||
}
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "取消",
|
||||
"cancelEditTooltip": "取消编辑",
|
||||
"copiedTooltip": "已复制",
|
||||
"copyTooltip": "复制消息到剪贴板",
|
||||
"editTooltip": "编辑消息"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "复制到剪贴板失败",
|
||||
"errorNotSupported": "您的浏览器不支持剪贴板API",
|
||||
@@ -277,6 +264,7 @@
|
||||
"continue": "继续",
|
||||
"control_after_generate": "生成后控制",
|
||||
"control_before_generate": "生成前控制",
|
||||
"copy": "复制",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"currentUser": "当前用户",
|
||||
"customize": "自定义",
|
||||
@@ -288,6 +276,7 @@
|
||||
"disableAll": "禁用全部",
|
||||
"disabling": "禁用中",
|
||||
"download": "下载",
|
||||
"edit": "编辑",
|
||||
"empty": "空",
|
||||
"enableAll": "启用全部",
|
||||
"enabled": "已启用",
|
||||
@@ -1354,6 +1343,7 @@
|
||||
"no3dSceneToExport": "没有3D场景可以导出",
|
||||
"noTemplatesToExport": "没有模板可以导出",
|
||||
"nodeDefinitionsUpdated": "节点定义已更新",
|
||||
"nothingSelected": "未选择任何内容",
|
||||
"nothingToGroup": "没有可分组的内容",
|
||||
"nothingToQueue": "没有可加入队列的内容",
|
||||
"pendingTasksDeleted": "待处理任务已删除",
|
||||
@@ -1362,6 +1352,7 @@
|
||||
"unableToGetModelFilePath": "无法获取模型文件路径",
|
||||
"unauthorizedDomain": "您的域名 {domain} 未被授权使用此服务。请联系 {email} 将您的域名添加到白名单。",
|
||||
"updateRequested": "已请求更新",
|
||||
"useApiKeyTip": "提示:无法正常登录?请使用 Comfy API Key 选项。",
|
||||
"userNotAuthenticated": "用户未认证"
|
||||
},
|
||||
"userSelect": {
|
||||
|
||||
@@ -87,6 +87,12 @@ const zProgressTextWsMessage = z.object({
|
||||
text: z.string()
|
||||
})
|
||||
|
||||
const zDisplayComponentWsMessage = z.object({
|
||||
node_id: zNodeId,
|
||||
component: z.enum(['ChatHistoryWidget']),
|
||||
props: z.record(z.string(), z.any()).optional()
|
||||
})
|
||||
|
||||
const zTerminalSize = z.object({
|
||||
cols: z.number(),
|
||||
row: z.number()
|
||||
@@ -120,6 +126,9 @@ export type ExecutionInterruptedWsMessage = z.infer<
|
||||
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
|
||||
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
|
||||
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
|
||||
export type DisplayComponentWsMessage = z.infer<
|
||||
typeof zDisplayComponentWsMessage
|
||||
>
|
||||
// End of ws messages
|
||||
|
||||
const zPromptInputItem = z.object({
|
||||
|
||||
@@ -216,6 +216,7 @@ const zComfyNode = z
|
||||
|
||||
const zGroup = z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
title: z.string(),
|
||||
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
||||
color: z.string().optional(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import type {
|
||||
DisplayComponentWsMessage,
|
||||
EmbeddingsResponse,
|
||||
ExecutedWsMessage,
|
||||
ExecutingWsMessage,
|
||||
@@ -103,6 +104,7 @@ interface BackendApiCalls {
|
||||
/** Binary preview/progress data */
|
||||
b_preview: Blob
|
||||
progress_text: ProgressTextWsMessage
|
||||
display_component: DisplayComponentWsMessage
|
||||
}
|
||||
|
||||
/** Dictionary of all api calls */
|
||||
|
||||
@@ -143,13 +143,20 @@ export class ComfyApp {
|
||||
_nodeOutputs: Record<string, any>
|
||||
nodePreviewImages: Record<string, string[]>
|
||||
// @ts-expect-error fixme ts strict error
|
||||
graph: LGraph
|
||||
#graph: LGraph
|
||||
get graph() {
|
||||
return this.#graph
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
canvas: LGraphCanvas
|
||||
dragOverNode: LGraphNode | null = null
|
||||
// @ts-expect-error fixme ts strict error
|
||||
canvasEl: HTMLCanvasElement
|
||||
configuringGraph: boolean = false
|
||||
|
||||
#configuringGraphLevel: number = 0
|
||||
get configuringGraph() {
|
||||
return this.#configuringGraphLevel > 0
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
ctx: CanvasRenderingContext2D
|
||||
bodyTop: HTMLElement
|
||||
@@ -692,17 +699,16 @@ export class ComfyApp {
|
||||
api.init()
|
||||
}
|
||||
|
||||
/** Flag that the graph is configuring to prevent nodes from running checks while its still loading */
|
||||
#addConfigureHandler() {
|
||||
const app = this
|
||||
const configure = LGraph.prototype.configure
|
||||
// Flag that the graph is configuring to prevent nodes from running checks while its still loading
|
||||
LGraph.prototype.configure = function () {
|
||||
app.configuringGraph = true
|
||||
LGraph.prototype.configure = function (...args) {
|
||||
app.#configuringGraphLevel++
|
||||
try {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return configure.apply(this, arguments)
|
||||
return configure.apply(this, args)
|
||||
} finally {
|
||||
app.configuringGraph = false
|
||||
app.#configuringGraphLevel--
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,14 +762,13 @@ export class ComfyApp {
|
||||
this.#addConfigureHandler()
|
||||
this.#addApiUpdateHandlers()
|
||||
|
||||
this.graph = new LGraph()
|
||||
this.#graph = new LGraph()
|
||||
|
||||
this.#addAfterConfigureHandler()
|
||||
|
||||
this.canvas = new LGraphCanvas(canvasEl, this.graph)
|
||||
// Make canvas states reactive so we can observe changes on them.
|
||||
this.canvas.state = reactive(this.canvas.state)
|
||||
this.canvas.ds.state = reactive(this.canvas.ds.state)
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.ctx = canvasEl.getContext('2d')
|
||||
@@ -1090,6 +1095,7 @@ export class ComfyApp {
|
||||
title: t('errorDialog.loadWorkflowTitle'),
|
||||
reportType: 'loadWorkflowError'
|
||||
})
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
for (const node of this.graph.nodes) {
|
||||
@@ -1229,6 +1235,7 @@ export class ComfyApp {
|
||||
title: t('errorDialog.promptExecutionError'),
|
||||
reportType: 'promptExecutionError'
|
||||
})
|
||||
console.error(error)
|
||||
|
||||
if (error instanceof PromptExecutionError) {
|
||||
executionStore.lastNodeErrors = error.response.node_errors ?? null
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
import { api } from './api'
|
||||
import type { ComfyApp } from './app'
|
||||
import { app } from './app'
|
||||
|
||||
function clone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
@@ -36,11 +37,6 @@ export class ChangeTracker {
|
||||
ds?: { scale: number; offset: [number, number] }
|
||||
nodeOutputs?: Record<string, any>
|
||||
|
||||
static app?: ComfyApp
|
||||
get app(): ComfyApp {
|
||||
return ChangeTracker.app!
|
||||
}
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The workflow that this change tracker is tracking
|
||||
@@ -68,18 +64,18 @@ export class ChangeTracker {
|
||||
|
||||
store() {
|
||||
this.ds = {
|
||||
scale: this.app.canvas.ds.scale,
|
||||
offset: [this.app.canvas.ds.offset[0], this.app.canvas.ds.offset[1]]
|
||||
scale: app.canvas.ds.scale,
|
||||
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
|
||||
}
|
||||
}
|
||||
|
||||
restore() {
|
||||
if (this.ds) {
|
||||
this.app.canvas.ds.scale = this.ds.scale
|
||||
this.app.canvas.ds.offset = this.ds.offset
|
||||
app.canvas.ds.scale = this.ds.scale
|
||||
app.canvas.ds.offset = this.ds.offset
|
||||
}
|
||||
if (this.nodeOutputs) {
|
||||
this.app.nodeOutputs = this.nodeOutputs
|
||||
app.nodeOutputs = this.nodeOutputs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +101,8 @@ export class ChangeTracker {
|
||||
}
|
||||
|
||||
checkState() {
|
||||
if (!this.app.graph || this.changeCount) return
|
||||
// @ts-expect-error zod type issue on ComfyWorkflowJSON. ComfyWorkflowJSON
|
||||
// is stricter than LiteGraph's serialisation schema.
|
||||
const currentState = clone(this.app.graph.serialize()) as ComfyWorkflowJSON
|
||||
if (!app.graph || this.changeCount) return
|
||||
const currentState = clone(app.graph.serialize()) as ComfyWorkflowJSON
|
||||
if (!this.activeState) {
|
||||
this.activeState = currentState
|
||||
return
|
||||
@@ -132,7 +126,7 @@ export class ChangeTracker {
|
||||
target.push(this.activeState)
|
||||
this.restoringState = true
|
||||
try {
|
||||
await this.app.loadGraphData(prevState, false, false, this.workflow, {
|
||||
await app.loadGraphData(prevState, false, false, this.workflow, {
|
||||
showMissingModelsDialog: false,
|
||||
showMissingNodesDialog: false,
|
||||
checkForRerouteMigration: false
|
||||
@@ -189,13 +183,11 @@ export class ChangeTracker {
|
||||
}
|
||||
}
|
||||
|
||||
static init(app: ComfyApp) {
|
||||
static init() {
|
||||
const getCurrentChangeTracker = () =>
|
||||
useWorkflowStore().activeWorkflow?.changeTracker
|
||||
const checkState = () => getCurrentChangeTracker()?.checkState()
|
||||
|
||||
ChangeTracker.app = app
|
||||
|
||||
let keyIgnored = false
|
||||
window.addEventListener(
|
||||
'keydown',
|
||||
@@ -237,7 +229,7 @@ export class ChangeTracker {
|
||||
if (await changeTracker.undoRedo(e)) return
|
||||
|
||||
// If our active element is some type of input then handle changes after they're done
|
||||
if (ChangeTracker.bindInput(app, bindInputEl)) return
|
||||
if (ChangeTracker.bindInput(bindInputEl)) return
|
||||
logger.debug('checkState on keydown')
|
||||
changeTracker.checkState()
|
||||
})
|
||||
@@ -339,7 +331,7 @@ export class ChangeTracker {
|
||||
})
|
||||
}
|
||||
|
||||
static bindInput(_app: ComfyApp, activeEl: Element | null): boolean {
|
||||
static bindInput(activeEl: Element | null): boolean {
|
||||
if (
|
||||
!activeEl ||
|
||||
activeEl.tagName === 'CANVAS' ||
|
||||
|
||||
@@ -47,10 +47,13 @@ export interface DOMWidget<T extends HTMLElement, V extends object | string>
|
||||
/**
|
||||
* A DOM widget that wraps a Vue component as a litegraph widget.
|
||||
*/
|
||||
export interface ComponentWidget<V extends object | string>
|
||||
extends BaseDOMWidget<V> {
|
||||
export interface ComponentWidget<
|
||||
V extends object | string,
|
||||
P = Record<string, unknown>
|
||||
> extends BaseDOMWidget<V> {
|
||||
readonly component: Component
|
||||
readonly inputSpec: InputSpec
|
||||
readonly props?: P
|
||||
}
|
||||
|
||||
export interface DOMWidgetOptions<V extends object | string>
|
||||
@@ -217,18 +220,23 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentWidgetImpl<V extends object | string>
|
||||
export class ComponentWidgetImpl<
|
||||
V extends object | string,
|
||||
P = Record<string, unknown>
|
||||
>
|
||||
extends BaseDOMWidgetImpl<V>
|
||||
implements ComponentWidget<V>
|
||||
implements ComponentWidget<V, P>
|
||||
{
|
||||
readonly component: Component
|
||||
readonly inputSpec: InputSpec
|
||||
readonly props?: P
|
||||
|
||||
constructor(obj: {
|
||||
node: LGraphNode
|
||||
name: string
|
||||
component: Component
|
||||
inputSpec: InputSpec
|
||||
props?: P
|
||||
options: DOMWidgetOptions<V>
|
||||
}) {
|
||||
super({
|
||||
@@ -237,6 +245,7 @@ export class ComponentWidgetImpl<V extends object | string>
|
||||
})
|
||||
this.component = obj.component
|
||||
this.inputSpec = obj.inputSpec
|
||||
this.props = obj.props
|
||||
}
|
||||
|
||||
override computeLayoutSize() {
|
||||
|
||||
257
src/services/README.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Services
|
||||
|
||||
This directory contains the service layer for the ComfyUI frontend application. Services encapsulate application logic and functionality into organized, reusable modules.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Service Architecture](#service-architecture)
|
||||
- [Core Services](#core-services)
|
||||
- [Service Development Guidelines](#service-development-guidelines)
|
||||
- [Common Design Patterns](#common-design-patterns)
|
||||
|
||||
## Overview
|
||||
|
||||
Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
|
||||
|
||||
The term "business logic" in this context refers to the code that implements the core functionality and behavior of the application - the rules, processes, and operations that make ComfyUI work as expected, separate from the UI display code.
|
||||
|
||||
Services help organize related functionality into cohesive units, making the codebase more maintainable and testable. By centralizing related operations in services, the application achieves better separation of concerns, with UI components focusing on presentation and services handling functional operations.
|
||||
|
||||
## Service Architecture
|
||||
|
||||
The service layer in ComfyUI follows these architectural principles:
|
||||
|
||||
1. **Domain-driven**: Each service focuses on a specific domain of the application
|
||||
2. **Stateless when possible**: Services generally avoid maintaining internal state
|
||||
3. **Reusable**: Services can be used across multiple components
|
||||
4. **Testable**: Services are designed for easy unit testing
|
||||
5. **Isolated**: Services have clear boundaries and dependencies
|
||||
|
||||
While services can interact with both UI components and stores (centralized state), they primarily focus on implementing functionality rather than managing state. The following diagram illustrates how services fit into the application architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ UI Components │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Composables │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Services │
|
||||
│ │
|
||||
│ (Application Functionality) │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────────┐ ┌─────────────────────────┐
|
||||
│ Stores │ │ External APIs │
|
||||
│ (Centralized State) │ │ │
|
||||
└───────────────────────────┘ └─────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Services
|
||||
|
||||
The core services include:
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| algoliaSearchService.ts | Implements search functionality using Algolia |
|
||||
| autoQueueService.ts | Manages automatic queue execution |
|
||||
| colorPaletteService.ts | Handles color palette management and customization |
|
||||
| comfyManagerService.ts | Manages ComfyUI application packages and updates |
|
||||
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions |
|
||||
| dialogService.ts | Provides dialog and modal management |
|
||||
| extensionService.ts | Manages extension registration and lifecycle |
|
||||
| keybindingService.ts | Handles keyboard shortcuts and keybindings |
|
||||
| litegraphService.ts | Provides utilities for working with the LiteGraph library |
|
||||
| load3dService.ts | Manages 3D model loading and visualization |
|
||||
| nodeSearchService.ts | Implements node search functionality |
|
||||
| workflowService.ts | Handles workflow operations (save, load, execute) |
|
||||
|
||||
## Service Development Guidelines
|
||||
|
||||
In ComfyUI, services can be implemented using two approaches:
|
||||
|
||||
### 1. Class-based Services
|
||||
|
||||
For complex services with state management and multiple methods, class-based services are used:
|
||||
|
||||
```typescript
|
||||
export class NodeSearchService {
|
||||
// Service state
|
||||
private readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
|
||||
private readonly filters: Record<string, FuseFilter<ComfyNodeDefImpl, string>>
|
||||
|
||||
constructor(data: ComfyNodeDefImpl[]) {
|
||||
// Initialize state
|
||||
this.nodeFuseSearch = new FuseSearch(data, { /* options */ })
|
||||
|
||||
// Setup filters
|
||||
this.filters = {
|
||||
inputType: new FuseFilter<ComfyNodeDefImpl, string>(/* options */),
|
||||
category: new FuseFilter<ComfyNodeDefImpl, string>(/* options */)
|
||||
}
|
||||
}
|
||||
|
||||
public searchNode(query: string, filters: FuseFilterWithValue[] = []): ComfyNodeDefImpl[] {
|
||||
// Implementation
|
||||
return results
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Composable-style Services
|
||||
|
||||
For simpler services or those that need to integrate with Vue's reactivity system, we prefer using composable-style services:
|
||||
|
||||
```typescript
|
||||
export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
|
||||
// State (reactive if needed)
|
||||
const data = ref(initialData)
|
||||
|
||||
// Search functionality
|
||||
function searchNodes(query: string) {
|
||||
// Implementation
|
||||
return results
|
||||
}
|
||||
|
||||
// Additional methods
|
||||
function refreshData(newData: ComfyNodeDefImpl[]) {
|
||||
data.value = newData
|
||||
}
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
searchNodes,
|
||||
refreshData
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When deciding between these approaches, consider:
|
||||
|
||||
1. **Stateful vs. Stateless**: For stateful services, classes often provide clearer encapsulation
|
||||
2. **Reactivity needs**: If the service needs to be reactive, composable-style services integrate better with Vue's reactivity system
|
||||
3. **Complexity**: For complex services with many methods and internal state, classes can provide better organization
|
||||
4. **Testing**: Both approaches can be tested effectively, but composables may be simpler to test with Vue Test Utils
|
||||
|
||||
### Service Template
|
||||
|
||||
Here's a template for creating a new composable-style service:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Service for managing [domain/functionality]
|
||||
*/
|
||||
export function useExampleService() {
|
||||
// Private state/functionality
|
||||
const cache = new Map()
|
||||
|
||||
/**
|
||||
* Description of what this method does
|
||||
* @param param1 Description of parameter
|
||||
* @returns Description of return value
|
||||
*/
|
||||
async function performOperation(param1: string) {
|
||||
try {
|
||||
// Implementation
|
||||
return result
|
||||
} catch (error) {
|
||||
// Error handling
|
||||
console.error(`Operation failed: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
performOperation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Design Patterns
|
||||
|
||||
Services in ComfyUI frequently use the following design patterns:
|
||||
|
||||
### Caching and Request Deduplication
|
||||
|
||||
```typescript
|
||||
export function useCachedService() {
|
||||
const cache = new Map()
|
||||
const pendingRequests = new Map()
|
||||
|
||||
async function fetchData(key: string) {
|
||||
// Check cache first
|
||||
if (cache.has(key)) return cache.get(key)
|
||||
|
||||
// Check if request is already in progress
|
||||
if (pendingRequests.has(key)) {
|
||||
return pendingRequests.get(key)
|
||||
}
|
||||
|
||||
// Perform new request
|
||||
const requestPromise = fetch(`/api/${key}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
cache.set(key, data)
|
||||
pendingRequests.delete(key)
|
||||
return data
|
||||
})
|
||||
|
||||
pendingRequests.set(key, requestPromise)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
return { fetchData }
|
||||
}
|
||||
```
|
||||
|
||||
### Factory Pattern
|
||||
|
||||
```typescript
|
||||
export function useNodeFactory() {
|
||||
function createNode(type: string, config: Record<string, any>) {
|
||||
// Create node based on type and configuration
|
||||
switch (type) {
|
||||
case 'basic':
|
||||
return { /* basic node implementation */ }
|
||||
case 'complex':
|
||||
return { /* complex node implementation */ }
|
||||
default:
|
||||
throw new Error(`Unknown node type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { createNode }
|
||||
}
|
||||
```
|
||||
|
||||
### Facade Pattern
|
||||
|
||||
```typescript
|
||||
export function useWorkflowService(
|
||||
apiService,
|
||||
graphService,
|
||||
storageService
|
||||
) {
|
||||
// Provides a simple interface to complex subsystems
|
||||
async function saveWorkflow(name: string) {
|
||||
const graphData = graphService.serializeGraph()
|
||||
const storagePath = await storageService.getPath(name)
|
||||
return apiService.saveData(storagePath, graphData)
|
||||
}
|
||||
|
||||
return { saveWorkflow }
|
||||
}
|
||||
```
|
||||
|
||||
For more detailed information about the service layer pattern and its applications, refer to:
|
||||
- [Service Layer Pattern](https://en.wikipedia.org/wiki/Service_layer_pattern)
|
||||
- [Service-Orientation](https://en.wikipedia.org/wiki/Service-orientation)
|
||||
@@ -1,12 +1,18 @@
|
||||
import { searchClient as algoliasearch } from '@algolia/client-search'
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
import type {
|
||||
BaseSearchParamsWithoutQuery,
|
||||
Hit,
|
||||
SearchQuery,
|
||||
SearchResponse
|
||||
} from 'algoliasearch/dist/lite/browser'
|
||||
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
||||
import { omit } from 'lodash'
|
||||
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { paramsToCacheKey } from '@/utils/formatUtil'
|
||||
|
||||
const DEFAULT_MAX_CACHE_SIZE = 64
|
||||
const DEFAULT_MIN_CHARS_FOR_SUGGESTIONS = 2
|
||||
|
||||
type SafeNestedProperty<
|
||||
T,
|
||||
@@ -15,6 +21,10 @@ type SafeNestedProperty<
|
||||
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
|
||||
|
||||
type RegistryNodePack = components['schemas']['Node']
|
||||
type SearchPacksResult = {
|
||||
nodePacks: Hit<AlgoliaNodePack>[]
|
||||
querySuggestions: Hit<NodesIndexSuggestion>[]
|
||||
}
|
||||
|
||||
export interface AlgoliaNodePack {
|
||||
objectID: RegistryNodePack['id']
|
||||
@@ -91,8 +101,41 @@ type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
|
||||
restrictSearchableAttributes: SearchAttribute[]
|
||||
}
|
||||
|
||||
export const useAlgoliaSearchService = () => {
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
|
||||
interface AlgoliaSearchServiceOptions {
|
||||
/**
|
||||
* Maximum number of search results to store in the cache.
|
||||
* The cache is automatically cleared when the component is unmounted.
|
||||
* @default 64
|
||||
*/
|
||||
maxCacheSize?: number
|
||||
/**
|
||||
* Minimum number of characters for suggestions. An additional query
|
||||
* will be made to the suggestions/completions index for queries that
|
||||
* are this length or longer.
|
||||
* @default 3
|
||||
*/
|
||||
minCharsForSuggestions?: number
|
||||
}
|
||||
|
||||
export const useAlgoliaSearchService = (
|
||||
options: AlgoliaSearchServiceOptions = {}
|
||||
) => {
|
||||
const {
|
||||
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
|
||||
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
|
||||
} = options
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__, {
|
||||
hosts: [
|
||||
{
|
||||
url: 'https://search.comfy.org',
|
||||
accept: 'read',
|
||||
protocol: 'https'
|
||||
}
|
||||
]
|
||||
})
|
||||
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
||||
maxSize: maxCacheSize
|
||||
})
|
||||
|
||||
const toRegistryLatestVersion = (
|
||||
algoliaNode: AlgoliaNodePack
|
||||
@@ -141,34 +184,39 @@ export const useAlgoliaSearchService = () => {
|
||||
const searchPacks = async (
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<{
|
||||
nodePacks: Hit<AlgoliaNodePack>[]
|
||||
querySuggestions: Hit<NodesIndexSuggestion>[]
|
||||
}> => {
|
||||
): Promise<SearchPacksResult> => {
|
||||
const { pageSize, pageNumber } = params
|
||||
const rest = omit(params, ['pageSize', 'pageNumber'])
|
||||
|
||||
const requests: SearchQuery[] = [
|
||||
{
|
||||
query,
|
||||
indexName: 'nodes_index',
|
||||
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
|
||||
...rest,
|
||||
hitsPerPage: pageSize,
|
||||
page: pageNumber
|
||||
}
|
||||
]
|
||||
|
||||
const shouldQuerySuggestions = query.length >= minCharsForSuggestions
|
||||
|
||||
// If the query is long enough, also query the suggestions index
|
||||
if (shouldQuerySuggestions) {
|
||||
requests.push({
|
||||
indexName: 'nodes_index_query_suggestions',
|
||||
query
|
||||
})
|
||||
}
|
||||
|
||||
const { results } = await searchClient.search<
|
||||
AlgoliaNodePack | NodesIndexSuggestion
|
||||
>({
|
||||
requests: [
|
||||
{
|
||||
query,
|
||||
indexName: 'nodes_index',
|
||||
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
|
||||
...rest,
|
||||
hitsPerPage: pageSize,
|
||||
page: pageNumber
|
||||
},
|
||||
{
|
||||
indexName: 'nodes_index_query_suggestions',
|
||||
query
|
||||
}
|
||||
],
|
||||
requests,
|
||||
strategy: 'none'
|
||||
})
|
||||
|
||||
const [nodePacks, querySuggestions] = results as [
|
||||
const [nodePacks, querySuggestions = { hits: [] }] = results as [
|
||||
SearchResponse<AlgoliaNodePack>,
|
||||
SearchResponse<NodesIndexSuggestion>
|
||||
]
|
||||
@@ -179,8 +227,27 @@ export const useAlgoliaSearchService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const searchPacksCached = async (
|
||||
query: string,
|
||||
params: SearchNodePacksParams
|
||||
): Promise<SearchPacksResult> => {
|
||||
const cacheKey = paramsToCacheKey({ query, ...params })
|
||||
const cachedResult = searchPacksCache.get(cacheKey)
|
||||
if (cachedResult !== undefined) return cachedResult
|
||||
|
||||
const result = await searchPacks(query, params)
|
||||
searchPacksCache.set(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const clearSearchPacksCache = () => {
|
||||
searchPacksCache.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
searchPacks,
|
||||
toRegistryPack
|
||||
searchPacksCached,
|
||||
toRegistryPack,
|
||||
clearSearchPacksCache
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ApiNodesNewsContent from '@/components/dialog/content/ApiNodesNewsContent.vue'
|
||||
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
|
||||
@@ -380,32 +379,6 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a dialog for the API nodes news.
|
||||
* TODO: Remove the news dialog on next major feature release.
|
||||
*/
|
||||
function showApiNodesNewsDialog() {
|
||||
if (localStorage.getItem('api-nodes-news-seen') === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
return dialogStore.showDialog({
|
||||
key: 'api-nodes-news',
|
||||
component: ApiNodesNewsContent,
|
||||
props: {
|
||||
dismissableMask: true,
|
||||
onClose: () => {
|
||||
dialogStore.closeDialog({ key: 'api-nodes-news' })
|
||||
localStorage.setItem('api-nodes-news-seen', 'true')
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
position: 'bottomright'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
showLoadWorkflowWarning,
|
||||
showMissingModelsWarning,
|
||||
@@ -421,7 +394,6 @@ export const useDialogService = () => {
|
||||
showSignInDialog,
|
||||
showTopUpCreditsDialog,
|
||||
showUpdatePasswordDialog,
|
||||
showApiNodesNewsDialog,
|
||||
prompt,
|
||||
confirm
|
||||
}
|
||||
|
||||
356
src/stores/README.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# Stores
|
||||
|
||||
This directory contains Pinia stores for the ComfyUI frontend application. Stores provide centralized state management for the application.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Store Architecture](#store-architecture)
|
||||
- [Core Stores](#core-stores)
|
||||
- [Store Development Guidelines](#store-development-guidelines)
|
||||
- [Common Patterns](#common-patterns)
|
||||
- [Testing Stores](#testing-stores)
|
||||
|
||||
## Overview
|
||||
|
||||
Stores in ComfyUI use [Pinia](https://pinia.vuejs.org/), Vue's official state management library. Each store is responsible for managing a specific domain of the application state, such as user data, workflow information, graph state, and UI configuration.
|
||||
|
||||
Stores provide a way to maintain global application state that can be accessed from any component, regardless of where those components are in the component hierarchy. This solves the problem of "prop drilling" (passing data down through multiple levels of components) and allows components that aren't directly related to share and modify the same state.
|
||||
|
||||
For example, without global state:
|
||||
```
|
||||
App
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ │
|
||||
HeaderBar Canvas
|
||||
│ │
|
||||
│ │
|
||||
UserMenu NodeProperties
|
||||
```
|
||||
|
||||
In this structure, if the `UserMenu` component needs to update something that affects `NodeProperties`, the data would need to be passed up to `App` and then down again, through all intermediate components.
|
||||
|
||||
With Pinia stores, components can directly access and update the shared state:
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ │
|
||||
│ Pinia Stores │
|
||||
│ │
|
||||
└───────┬─────────┘
|
||||
│
|
||||
│ Accessed by
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ │
|
||||
│ Components │
|
||||
│ │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
## Store Architecture
|
||||
|
||||
The store architecture in ComfyUI follows these principles:
|
||||
|
||||
1. **Domain-driven**: Each store focuses on a specific domain of the application
|
||||
2. **Single source of truth**: Stores serve as the definitive source for specific data
|
||||
3. **Composition**: Stores can interact with other stores when needed
|
||||
4. **Actions for logic**: Business logic is encapsulated in store actions
|
||||
5. **Getters for derived state**: Computed values are exposed via getters
|
||||
|
||||
The following diagram illustrates the store architecture and data flow:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Vue Components │
|
||||
│ │
|
||||
│ ┌───────────────┐ ┌───────────────┐ │
|
||||
│ │ Component A │ │ Component B │ │
|
||||
│ └───────┬───────┘ └───────┬───────┘ │
|
||||
│ │ │ │
|
||||
└───────────┼────────────────────────────┼────────────────┘
|
||||
│ │
|
||||
│ ┌───────────────┐ │
|
||||
└────►│ Composables │◄─────┘
|
||||
└───────┬───────┘
|
||||
│
|
||||
┌─────────────────────────┼─────────────────────────────┐
|
||||
│ Pinia Stores │ │
|
||||
│ │ │
|
||||
│ ┌───────────────────▼───────────────────────┐ │
|
||||
│ │ Actions │ │
|
||||
│ └───────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────▼───────────────────────┐ │
|
||||
│ │ State │ │
|
||||
│ └───────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────▼───────────────────────┐ │
|
||||
│ │ Getters │ │
|
||||
│ └───────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
└─────────────────────────┼─────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ External Services │
|
||||
│ (API, localStorage, WebSocket, etc.) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Stores
|
||||
|
||||
The core stores include:
|
||||
|
||||
| Store | Description |
|
||||
|-------|-------------|
|
||||
| aboutPanelStore.ts | Manages the About panel state and badges |
|
||||
| apiKeyAuthStore.ts | Handles API key authentication |
|
||||
| comfyManagerStore.ts | Manages ComfyUI application state |
|
||||
| comfyRegistryStore.ts | Handles extensions registry |
|
||||
| commandStore.ts | Manages commands and command execution |
|
||||
| dialogStore.ts | Controls dialog/modal display and state |
|
||||
| domWidgetStore.ts | Manages DOM widget state |
|
||||
| executionStore.ts | Tracks workflow execution state |
|
||||
| extensionStore.ts | Manages extension registration and state |
|
||||
| firebaseAuthStore.ts | Handles Firebase authentication |
|
||||
| graphStore.ts | Manages the graph canvas state |
|
||||
| imagePreviewStore.ts | Controls image preview functionality |
|
||||
| keybindingStore.ts | Manages keyboard shortcuts |
|
||||
| menuItemStore.ts | Handles menu items and their state |
|
||||
| modelStore.ts | Manages AI models information |
|
||||
| nodeDefStore.ts | Manages node definitions |
|
||||
| queueStore.ts | Handles the execution queue |
|
||||
| settingStore.ts | Manages application settings |
|
||||
| userStore.ts | Manages user data and preferences |
|
||||
| workflowStore.ts | Handles workflow data and operations |
|
||||
| workspace/* | Stores related to the workspace UI |
|
||||
|
||||
## Store Development Guidelines
|
||||
|
||||
When developing or modifying stores, follow these best practices:
|
||||
|
||||
1. **Define clear purpose**: Each store should have a specific responsibility
|
||||
2. **Use actions for async operations**: Encapsulate asynchronous logic in actions
|
||||
3. **Keep stores focused**: Each store should manage related state
|
||||
4. **Document public API**: Add comments for state properties, actions, and getters
|
||||
5. **Use getters for derived state**: Compute derived values using getters
|
||||
6. **Test store functionality**: Write unit tests for stores
|
||||
|
||||
### Store Template
|
||||
|
||||
Here's a template for creating a new Pinia store, following the setup style used in ComfyUI:
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useExampleStore = defineStore('example', () => {
|
||||
// State
|
||||
const items = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const itemCount = computed(() => items.value.length)
|
||||
const hasError = computed(() => error.value !== null)
|
||||
|
||||
// Actions
|
||||
function addItem(item) {
|
||||
items.value.push(item)
|
||||
}
|
||||
|
||||
async function fetchItems() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/items')
|
||||
const data = await response.json()
|
||||
items.value = data
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Expose state, getters, and actions
|
||||
return {
|
||||
// State
|
||||
items,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
itemCount,
|
||||
hasError,
|
||||
|
||||
// Actions
|
||||
addItem,
|
||||
fetchItems
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Stores in ComfyUI frequently use these patterns:
|
||||
|
||||
### API Integration
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
export const useDataStore = defineStore('data', () => {
|
||||
const data = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api.getData()
|
||||
data.value = result
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
fetchData
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Store Composition
|
||||
|
||||
```typescript
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useOtherStore } from './otherStore'
|
||||
|
||||
export const useComposedStore = defineStore('composed', () => {
|
||||
const otherStore = useOtherStore()
|
||||
const { someData } = storeToRefs(otherStore)
|
||||
|
||||
// Local state
|
||||
const localState = ref(0)
|
||||
|
||||
// Computed value based on other store
|
||||
const derivedValue = computed(() => {
|
||||
return computeFromOtherData(someData.value, localState.value)
|
||||
})
|
||||
|
||||
// Action that uses another store
|
||||
async function complexAction() {
|
||||
await otherStore.someAction()
|
||||
localState.value += 1
|
||||
}
|
||||
|
||||
return {
|
||||
localState,
|
||||
derivedValue,
|
||||
complexAction
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Persistent State
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export const usePreferencesStore = defineStore('preferences', () => {
|
||||
// Load from localStorage if available
|
||||
const theme = ref(localStorage.getItem('theme') || 'light')
|
||||
const fontSize = ref(parseInt(localStorage.getItem('fontSize') || '14'))
|
||||
|
||||
// Save to localStorage when changed
|
||||
watch(theme, (newTheme) => {
|
||||
localStorage.setItem('theme', newTheme)
|
||||
})
|
||||
|
||||
watch(fontSize, (newSize) => {
|
||||
localStorage.setItem('fontSize', newSize.toString())
|
||||
})
|
||||
|
||||
function setTheme(newTheme) {
|
||||
theme.value = newTheme
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
fontSize,
|
||||
setTheme
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Stores
|
||||
|
||||
Stores should be tested to ensure they behave as expected. Here's an example of how to test a store:
|
||||
|
||||
```typescript
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExampleStore } from '@/stores/exampleStore'
|
||||
|
||||
// Mock API dependencies
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useExampleStore', () => {
|
||||
let store: ReturnType<typeof useExampleStore>
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh pinia instance and make it active
|
||||
setActivePinia(createPinia())
|
||||
store = useExampleStore()
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should add an item', () => {
|
||||
store.addItem('test')
|
||||
expect(store.items).toEqual(['test'])
|
||||
expect(store.itemCount).toBe(1)
|
||||
})
|
||||
|
||||
it('should fetch items', async () => {
|
||||
// Setup mock response
|
||||
vi.mocked(api.getData).mockResolvedValue(['item1', 'item2'])
|
||||
|
||||
// Call the action
|
||||
await store.fetchItems()
|
||||
|
||||
// Verify state changes
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.items).toEqual(['item1', 'item2'])
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
For more information on Pinia, refer to the [Pinia documentation](https://pinia.vuejs.org/introduction.html).
|
||||
@@ -2,14 +2,14 @@
|
||||
* Stores all DOM widgets that are used in the canvas.
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { markRaw, ref } from 'vue'
|
||||
import { type Raw, markRaw, ref } from 'vue'
|
||||
|
||||
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
|
||||
export interface DomWidgetState extends PositionConfig {
|
||||
// Raw widget instance
|
||||
widget: BaseDOMWidget<object | string>
|
||||
widget: Raw<BaseDOMWidget<object | string>>
|
||||
visible: boolean
|
||||
readonly: boolean
|
||||
zIndex: number
|
||||
@@ -23,7 +23,7 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
|
||||
widget: BaseDOMWidget<V>
|
||||
) => {
|
||||
widgetStates.value.set(widget.id, {
|
||||
widget: markRaw(widget) as unknown as BaseDOMWidget<object | string>,
|
||||
widget: markRaw(widget) as unknown as Raw<BaseDOMWidget<object | string>>,
|
||||
visible: true,
|
||||
readonly: false,
|
||||
zIndex: 0,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory'
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import type {
|
||||
DisplayComponentWsMessage,
|
||||
ExecutedWsMessage,
|
||||
ExecutionCachedWsMessage,
|
||||
ExecutionErrorWsMessage,
|
||||
@@ -17,6 +20,7 @@ import type {
|
||||
NodeId
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
import { ComfyWorkflow } from './workflowStore'
|
||||
|
||||
@@ -106,6 +110,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
)
|
||||
}
|
||||
api.addEventListener('progress_text', handleProgressText as EventListener)
|
||||
api.addEventListener(
|
||||
'display_component',
|
||||
handleDisplayComponent as EventListener
|
||||
)
|
||||
|
||||
function unbindExecutionEvents() {
|
||||
api.removeEventListener(
|
||||
@@ -194,6 +202,21 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
useNodeProgressText().showTextPreview(node, text)
|
||||
}
|
||||
|
||||
function handleDisplayComponent(e: CustomEvent<DisplayComponentWsMessage>) {
|
||||
const { node_id, component, props = {} } = e.detail
|
||||
const node = app.graph.getNodeById(node_id)
|
||||
if (!node) return
|
||||
|
||||
if (component === 'ChatHistoryWidget') {
|
||||
useNodeChatHistory({
|
||||
props: props as Omit<
|
||||
InstanceType<typeof ChatHistoryWidget>['$props'],
|
||||
'widget'
|
||||
>
|
||||
}).showChatHistory(node)
|
||||
}
|
||||
}
|
||||
|
||||
function storePrompt({
|
||||
nodes,
|
||||
id,
|
||||
|
||||
@@ -16,7 +16,11 @@ export const ALWAYS_DISABLED_EXTENSIONS: readonly string[] = [
|
||||
// pysssss.SnapToGrid tries to write global app.shiftDown state, which is no longer
|
||||
// allowed since v1.3.12.
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/1176
|
||||
'pysssss.SnapToGrid'
|
||||
'pysssss.SnapToGrid',
|
||||
// Favicon status is implemented in ComfyUI core in v1.20.
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/3880
|
||||
'pysssss.FaviconStatus',
|
||||
'KJNodes.browserstatus'
|
||||
]
|
||||
|
||||
export const useExtensionStore = defineStore('extension', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { LGraphCanvas, LGraphGroup, LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { Positionable } from '@comfyorg/litegraph/dist/interfaces'
|
||||
import { defineStore } from 'pinia'
|
||||
import { markRaw, ref, shallowRef } from 'vue'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
|
||||
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
export const useTitleEditorStore = defineStore('titleEditor', () => {
|
||||
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
|
||||
@@ -15,18 +17,21 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
/**
|
||||
* The LGraphCanvas instance.
|
||||
*
|
||||
* The root LGraphCanvas object is shallow reactive.
|
||||
* The root LGraphCanvas object is a shallow ref.
|
||||
*/
|
||||
const canvas = shallowRef<LGraphCanvas | null>(null)
|
||||
/**
|
||||
* The selected items on the canvas. All stored items are raw.
|
||||
*/
|
||||
const selectedItems = ref<Positionable[]>([])
|
||||
const selectedItems = ref<Raw<Positionable>[]>([])
|
||||
const updateSelectedItems = () => {
|
||||
const items = Array.from(canvas.value?.selectedItems ?? [])
|
||||
selectedItems.value = items.map((item) => markRaw(item))
|
||||
}
|
||||
|
||||
const nodeSelected = computed(() => selectedItems.value.some(isLGraphNode))
|
||||
const groupSelected = computed(() => selectedItems.value.some(isLGraphGroup))
|
||||
|
||||
const getCanvas = () => {
|
||||
if (!canvas.value) throw new Error('getCanvas: canvas is null')
|
||||
return canvas.value
|
||||
@@ -35,6 +40,8 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
return {
|
||||
canvas,
|
||||
selectedItems,
|
||||
nodeSelected,
|
||||
groupSelected,
|
||||
updateSelectedItems,
|
||||
getCanvas
|
||||
}
|
||||
|
||||
@@ -292,6 +292,8 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
|
||||
}
|
||||
function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null {
|
||||
// Frontend-only nodes don't have nodeDef
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Optional chaining used in index
|
||||
return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { SidebarTabExtension, ToastManager } from '@/types/extensionTypes'
|
||||
|
||||
import { useApiKeyAuthStore } from './apiKeyAuthStore'
|
||||
import { useCommandStore } from './commandStore'
|
||||
import { useFirebaseAuthStore } from './firebaseAuthStore'
|
||||
import { useQueueSettingsStore } from './queueStore'
|
||||
import { useSettingStore } from './settingStore'
|
||||
import { useToastStore } from './toastStore'
|
||||
@@ -43,6 +45,18 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const dialog = useDialogService()
|
||||
const bottomPanel = useBottomPanelStore()
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const apiKeyStore = useApiKeyAuthStore()
|
||||
|
||||
const firebaseUser = computed(() => authStore.currentUser)
|
||||
const isApiKeyLogin = computed(() => apiKeyStore.isAuthenticated)
|
||||
const isLoggedIn = computed(
|
||||
() => !!isApiKeyLogin.value || firebaseUser.value !== null
|
||||
)
|
||||
const partialUserStore = {
|
||||
isLoggedIn
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a sidebar tab.
|
||||
* @param tab The sidebar tab to register.
|
||||
@@ -86,6 +100,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
colorPalette,
|
||||
dialog,
|
||||
bottomPanel,
|
||||
user: partialUserStore,
|
||||
|
||||
registerSidebarTab,
|
||||
unregisterSidebarTab,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LGraph } from '@comfyorg/litegraph'
|
||||
|
||||
import type {
|
||||
DeviceStats,
|
||||
EmbeddingsResponse,
|
||||
@@ -54,5 +56,11 @@ export type {
|
||||
}
|
||||
|
||||
declare global {
|
||||
const app: ComfyApp
|
||||
interface Window {
|
||||
/** For use by extensions and in the browser console. Where possible, import `app` from '@/scripts/app' instead. */
|
||||
app?: ComfyApp
|
||||
|
||||
/** For use by extensions and in the browser console. Where possible, import `app` and access via `app.graph` instead. */
|
||||
graph?: LGraph
|
||||
}
|
||||
}
|
||||
|
||||
8
src/types/litegraph-augmentation.d.ts
vendored
@@ -29,6 +29,12 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
|
||||
* The minimum size of the node if the widget is present.
|
||||
*/
|
||||
minNodeSize?: Size
|
||||
|
||||
/** If the widget is advanced, this will be set to true. */
|
||||
advanced?: boolean
|
||||
|
||||
/** If the widget is hidden, this will be set to true. */
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
interface IBaseWidget {
|
||||
@@ -60,7 +66,7 @@ declare module '@comfyorg/litegraph' {
|
||||
type?: string
|
||||
comfyClass: string
|
||||
title: string
|
||||
nodeData?: ComfyNodeDefV1 & ComfyNodeDefV2
|
||||
nodeData?: ComfyNodeDefV1 & ComfyNodeDefV2 & { [key: symbol]: unknown }
|
||||
category?: string
|
||||
new (): T
|
||||
}
|
||||
|
||||