Compare commits

..

13 Commits

Author SHA1 Message Date
Benjamin Lu
6afc2b62a7 chore(tsconfig): include scripts and tailwind config 2025-09-18 16:58:33 -07:00
AustinMroz
eb664f47af Fix cyclic prototype errors with subgraphNodes (#5637)
#5024 added support for connecting primitive nodes to subgraph inputs.
To accomplish this, it pulls WidgetLocator information from the node
owning the widget.

This `node` property does not exist on all IBaseWidget. `toConcrete` was
used to instead have a BaseWidget which is guaranteed to have a node
property. The issue that was missed, is that a widget which lacks this
information (such as most implemented by custom nodes) sets the node
value to the argument which was passed. Here that is the reference to
the subgraph node. Sometimes, this `#setWidget` call is made multiple
times, and when this occurs, the `input.widget` has itself set as the
protoyep, throwing an error.

This is resolved by instead taking an additional input which is
unambiguous.

For reference, this is a near minimal workflow using comfy_mtb that
replicates the issue

[cyclic.json](https://github.com/user-attachments/files/22412187/cyclic.json)

Special thanks to @melMass for assistance discovering this issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5637-Fix-cyclic-prototype-errors-with-subgraphNodes-2726d73d365081fea356f5197e4c2b42)
by [Unito](https://www.unito.io)
2025-09-18 16:06:12 -07:00
Christian Byrne
bc85d4e87b Make Vue nodes read-only when in panning mode (#5574)
## Summary

Integrated Vue node components with canvas panning mode to prevent UI
interference during navigation.

## Changes

- **What**: Added
[canCapturePointerEvents](https://docs.comfy.org/guide/vue-nodes)
computed property to `useCanvasInteractions` composable that checks
canvas read-only state
- **What**: Modified Vue node components (LGraphNode, NodeWidgets) to
conditionally handle pointer events based on canvas navigation mode
- **What**: Updated node event handlers to respect panning mode and
forward events to canvas when appropriate

## Review Focus

Event forwarding logic in panning mode and pointer event capture state
management across Vue node hierarchy.

```mermaid
graph TD
    A[User Interaction] --> B{Canvas in Panning Mode?}
    B -->|Yes| C[Forward to Canvas]
    B -->|No| D[Handle in Vue Component]
    C --> E[Canvas Navigation]
    D --> F[Node Selection/Widget Interaction]

    G[canCapturePointerEvents] --> H{read_only === false}
    H -->|Yes| I[Allow Vue Events]
    H -->|No| J[Block Vue Events]

    style A fill:#f9f9f9,stroke:#333,color:#000
    style E fill:#f9f9f9,stroke:#333,color:#000
    style F fill:#f9f9f9,stroke:#333,color:#000
    style I fill:#e1f5fe,stroke:#01579b,color:#000
    style J fill:#ffebee,stroke:#c62828,color:#000
```

## Screenshots




https://github.com/user-attachments/assets/00dc5e4a-2b56-43be-b92e-eaf511e52542

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5574-Make-Vue-nodes-read-only-when-in-panning-mode-26f6d73d3650818c951cd82c8fe58972)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-18 15:43:35 -07:00
Comfy Org PR Bot
7585444ce6 1.28.0 (#5640)
Minor version increment to 1.28.0

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5640-1-28-0-2726d73d3650818e846fcf78cbf33b73)
by [Unito](https://www.unito.io)

Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-09-18 14:13:33 -07:00
Robin Huang
a886798a10 Explicitly add email scope for social auth login. (#5638)
## Summary

Some users were authenticating successfully but their email addresses
weren't being extracted from the Firebase token. This happened because
we weren't explicitly requesting the email scope during OAuth
authentication.
 
While Firebase's default configuration includes basic profile info, it
doesn't guarantee email access for all account types - particularly
Google Workspace accounts with restrictive policies or users with
privacy-conscious settings.

[Github
Scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)

## Changes

Adding email scope for Google + Github social OAuth.

## Review Focus
N/A

## Screenshots (if applicable)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5638-Explicitly-add-email-scope-for-social-auth-login-2726d73d3650817ab356fc9c04f8641b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-09-18 14:09:16 -07:00
Christian Byrne
37975e4eac [test] Add component test for image compare widget (#5549)
## Summary

Added comprehensive component test suite for WidgetImageCompare widget
with 410 test assertions covering display, edge cases, and integration
scenarios.

## Changes

- **What**: Created [Vue Test Utils](https://vue-test-utils.vuejs.org/)
test suite for [WidgetImageCompare
component](src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue)
using [Vitest](https://vitest.dev/) testing framework

## Review Focus

Test coverage completeness for string vs object value handling,
accessibility attribute propagation, and edge case robustness including
malformed URLs and empty states.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5549-test-Add-component-test-for-image-compare-widget-26e6d73d365081189fe0d010f87d1eec)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-18 13:44:21 -07:00
Jin Yi
a41b8a6d4f refactor: Change manager flag from --disable-manager to --enable-manager (#5635)
## Summary
- Updated frontend to align with backend changes in ComfyUI core PR
#7555
- Changed manager startup argument from `--disable-manager` (opt-out) to
`--enable-manager` (opt-in)
- Manager is now disabled by default unless explicitly enabled

## Changes
- Modified `useManagerState.ts` to check for `--enable-manager` flag
presence
- Inverted logic: manager is disabled when the flag is NOT present
- Updated all related tests to reflect the new opt-in behavior
- Fixed edge case where `systemStats` is null

## Related
- Backend PR: https://github.com/comfyanonymous/ComfyUI/pull/7555

## Test Plan
- [x] All unit tests pass
- [x] Verified manager state logic with different flag combinations
- [x] TypeScript type checking passes
- [x] Linting passes

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5635-refactor-Change-manager-flag-from-disable-manager-to-enable-manager-2726d73d36508153a88bd9f152132b2a)
by [Unito](https://www.unito.io)
2025-09-18 11:45:07 -07:00
Alexander Brown
b264685052 lint: add tsconfig for browser_tests, fix existing violations (#5633)
## Summary

See https://typescript-eslint.io/blog/project-service/ for context.
Creates a browser_tests specific tsconfig so that they can be linted.

Does not add a package.json script to do the linting yet, but `pnpm exec
eslint browser_tests` should work for now.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5633-lint-add-tsconfig-for-browser_tests-fix-existing-violations-2726d73d3650819d8ef2c4b0abc31e14)
by [Unito](https://www.unito.io)
2025-09-18 11:35:44 -07:00
Johnpaul Chiwetelu
78d0ea6fa5 LazyImage on Safari (#5626)
This pull request improves the lazy loading behavior and caching
strategy for images in the `LazyImage.vue` component. The most
significant changes are focused on optimizing image rendering and
resource management, as well as improving code clarity.

**Lazy loading behavior improvements:**

* Changed the `<img>` element to render only when `cachedSrc` is
available, ensuring that images are not displayed before they are ready.
* Updated watchers in `LazyImage.vue` to use clearer variable names
(`shouldLoadVal` instead of `shouldLoad`) for better readability and
maintainability.
[[1]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L80-R81)
[[2]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L96-R96)

**Caching strategy enhancement:**

* Modified the `fetch` call in `mediaCacheService.ts` to use `{ cache:
'force-cache' }`, which leverages the browser's cache more aggressively
when loading media, potentially improving performance and reducing
network requests.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5626-LazyImage-on-Safari-2716d73d365081eeb1d3c2a96be4d408)
by [Unito](https://www.unito.io)
2025-09-18 11:20:19 -07:00
Christian Byrne
ea4e57b602 Move VueFire persistence configuration to initialization (#5614)
Currently, we set persistence method in the auth store setup. This
creates pattern of using the default on init (indexed DB) up until the
firebase store is initialized and `setPersistence` is called. For
devices that don't support indexed DB or have the connection aggresively
terminated or cleared, like
[Safari](https://comfy-org.sentry.io/issues/6879071102/?project=4509681221369857&query=is%3Aunresolved&referrer=issue-stream),
this can create problems with maintaing auth persistence.

Fix by setting persistence method in the initialization in main.ts

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5614-Move-VueFire-persistence-configuration-to-initialization-2716d73d3650817480e0c8feb1f37b9a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 11:18:05 -07:00
Johnpaul Chiwetelu
4789d86fe8 Line Selection toolbox up with Vue Nodes (#5601)
This pull request improves the selection toolbox behavior during node
dragging by ensuring that it correctly responds to both LiteGraph and
Vue node drag events. The main changes introduce a reactive drag state
for Vue nodes in the layout store and update the selection toolbox
composable and Vue node component to use this state.

**Selection toolbox behavior improvements:**

* Added a helper function and separate watchers in
`useSelectionToolboxPosition.ts` to hide the selection toolbox when
either LiteGraph or Vue nodes are being dragged. This ensures consistent
UI feedback regardless of node type.
[[1]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713L171-R172)
[[2]](diffhunk://#diff-57a51ac5e656e64ae7fd276d71b115058631621755de33b1eb8e8a4731d48713R212-R224)

**Vue node drag state management:**

* Added a reactive `isDraggingVueNodes` property to the
`LayoutStoreImpl` class, along with getter and setter methods to manage
Vue node drag state. This allows other components to reactively track
when Vue nodes are being dragged.
[[1]](diffhunk://#diff-80d32fe0fb72730c16cf7259adef8b20732ff214df240b1d39ae516737beaf3bR133-R135)
[[2]](diffhunk://#diff-80d32fe0fb72730c16cf7259adef8b20732ff214df240b1d39ae516737beaf3bR354-R367)
* Updated `LGraphNode.vue` to set and clear the Vue node dragging state
in the layout store during pointer down and up events, ensuring the
selection toolbox is hidden while dragging Vue nodes.
[[1]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R357-R360)
[[2]](diffhunk://#diff-a7744614cf842e54416047326db79ad81f7c7ab7bfb66ae2b46f5c73ac7d47f2R376-R378)

**Dependency updates:**

* Imported the `layoutStore` in `LGraphNode.vue` to access the new drag
state management methods.
* Added missing `ref` import in `layoutStore.ts` to support the new
reactive property.



https://github.com/user-attachments/assets/d6e9c15e-63b5-4de2-9688-ebbc6a3be545

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-18 11:17:14 -07:00
filtered
09e7d1040e Add desktop dialogs framework (#5605)
### Summary

Adds desktop dialog framework with data-driven dialog definitions.

### Changes

- Data-driven dialog structure in `desktopDialogs.ts`
- Dynamic dialog view component with i18n support
- Button action types: openUrl, close, cancel
- Button severity levels for styling (primary, secondary, danger, warn)
- Fallback invalid dialog for error handling
- i18n collection script updated for dialog strings
2025-09-17 20:32:53 -07:00
Arjan Singh
dfa1cbba4f Asset Browser Modal Component (#5607)
* [ci] ignore playwright mcp directory

* [feat] add AssetBrowserModal

And all related sub components

* [feat] reactive filter functions

* [ci] clean up storybook config

* [feat] add sematic AssetCard

* [fix] i love lucide

* [fix] AssetCard layout issues

* [fix] add AssetBadge type

* [fix] simplify useAssetBrowser

* [fix] modal layout

* [fix] simplify useAssetBrowserDialog

* [fix] add tailwind back to storybook

* [fix] better reponsive layout

* [fix] missed i18n string

* [fix] missing i18n translations

* [fix] remove erroneous prevent on keyboard.space

* [feat] add asset metadata validation utilities

* [fix] remove erroneous test code

* [fix] remove forced min and max width on AssetCard

* [fix] import statement nits
2025-09-17 16:17:09 -07:00
113 changed files with 3567 additions and 4303 deletions

4
.gitignore vendored
View File

@@ -78,8 +78,8 @@ vite.config.mts.timestamp-*.mjs
*storybook.log
storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data

View File

@@ -15,21 +15,32 @@ const config: StorybookConfig = {
async viteFinal(config) {
// Use dynamic import to avoid CJS deprecation warning
const { mergeConfig } = await import('vite')
const { default: tailwindcss } = await import('@tailwindcss/vite')
// Filter out any plugins that might generate import maps
if (config.plugins) {
config.plugins = config.plugins.filter((plugin: any) => {
if (plugin && plugin.name && plugin.name.includes('import-map')) {
return false
}
return true
})
config.plugins = config.plugins
// Type guard: ensure we have valid plugin objects with names
.filter(
(plugin): plugin is NonNullable<typeof plugin> & { name: string } => {
return (
plugin !== null &&
plugin !== undefined &&
typeof plugin === 'object' &&
'name' in plugin &&
typeof plugin.name === 'string'
)
}
)
// Business logic: filter out import-map plugins
.filter((plugin) => !plugin.name.includes('import-map'))
}
return mergeConfig(config, {
// Replace plugins entirely to avoid inheritance issues
plugins: [
// Only include plugins we explicitly need for Storybook
tailwindcss(),
Icons({
compiler: 'vue3',
customCollections: {

View File

@@ -1,7 +1,7 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import { setup } from '@storybook/vue3'
import type { Preview } from '@storybook/vue3-vite'
import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -9,11 +9,9 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import '../src/assets/css/style.css'
import { i18n } from '../src/i18n'
import '../src/lib/litegraph/public/css/litegraph.css'
import { useWidgetStore } from '../src/stores/widgetStore'
import { useColorPaletteStore } from '../src/stores/workspace/colorPaletteStore'
import '@/assets/css/style.css'
import { i18n } from '@/i18n'
import '@/lib/litegraph/public/css/litegraph.css'
const ComfyUIPreset = definePreset(Aura, {
semantic: {
@@ -25,13 +23,11 @@ const ComfyUIPreset = definePreset(Aura, {
// Setup Vue app for Storybook
setup((app) => {
app.directive('tooltip', Tooltip)
// Create Pinia instance
const pinia = createPinia()
app.use(pinia)
// Initialize stores
useColorPaletteStore(pinia)
useWidgetStore(pinia)
app.use(i18n)
app.use(PrimeVue, {
theme: {
@@ -50,8 +46,8 @@ setup((app) => {
app.use(ToastService)
})
// Dark theme decorator
export const withTheme = (Story: any, context: any) => {
// Theme and dialog decorator
export const withTheme = (Story: StoryFn, context: StoryContext) => {
const theme = context.globals.theme || 'light'
// Apply theme class to document root
@@ -63,7 +59,7 @@ export const withTheme = (Story: any, context: any) => {
document.body.classList.remove('dark-theme')
}
return Story()
return Story(context.args, context)
}
const preview: Preview = {

View File

@@ -1,4 +1,5 @@
import { Page, test as base } from '@playwright/test'
import type { Page } from '@playwright/test'
import { test as base } from '@playwright/test'
export class UserSelectPage {
constructor(

View File

@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}

View File

@@ -1,6 +1,6 @@
import { Page } from '@playwright/test'
import type { Page } from '@playwright/test'
import { ComfyPage } from '../ComfyPage'
import type { ComfyPage } from '../ComfyPage'
export class SettingDialog {
constructor(

View File

@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
class SidebarTab {
constructor(

View File

@@ -1,4 +1,5 @@
import { Locator, Page, expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
export class Topbar {
private readonly menuLocator: Locator

View File

@@ -12,9 +12,10 @@ export const webSocketFixture = base.extend<{
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor() {
// @ts-expect-error
super(...arguments)
constructor(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
store[this.url] = this
}
}

View File

@@ -1,4 +1,4 @@
import { FullConfig } from '@playwright/test'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { backupPath } from './utils/backupUtils'

View File

@@ -1,4 +1,4 @@
import { FullConfig } from '@playwright/test'
import type { FullConfig } from '@playwright/test'
import dotenv from 'dotenv'
import { restorePath } from './utils/backupUtils'

View File

@@ -1,4 +1,4 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
export class ManageGroupNode {
footer: Locator

View File

@@ -1,7 +1,7 @@
import { Locator, Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import path from 'path'
import {
import type {
TemplateInfo,
WorkflowTemplates
} from '../../src/platform/workflow/templates/types/template'

View File

@@ -29,9 +29,9 @@ test.describe('Actionbar', () => {
// Intercept the prompt queue endpoint
let promptNumber = 0
comfyPage.page.route('**/api/prompt', async (route, req) => {
await comfyPage.page.route('**/api/prompt', async (route, req) => {
await new Promise((r) => setTimeout(r, 100))
route.fulfill({
await route.fulfill({
status: 200,
body: JSON.stringify({
prompt_id: promptNumber,

View File

@@ -1,5 +1,5 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
ComfyPage,
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'

View File

@@ -1,4 +1,5 @@
import { Page, expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'

View File

@@ -1,4 +1,5 @@
import { Locator, expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { SettingParams } from '../../src/platform/settings/types'
import type { SettingParams } from '../../src/platform/settings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Topbar commands', () => {

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Group Node', () => {

View File

@@ -1,12 +1,13 @@
import { Locator, expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Remote COMBO Widget', () => {
const mockOptions = ['d', 'c', 'b', 'a']

View File

@@ -160,7 +160,9 @@ test.describe.skip('Queue sidebar', () => {
comfyPage
}) => {
await comfyPage.nextFrame()
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test('maintains active gallery item when new tasks are added', async ({
@@ -174,7 +176,9 @@ test.describe.skip('Queue sidebar', () => {
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
await newTask.waitFor({ state: 'visible' })
// The active gallery item should still be the initial image
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test.describe('Gallery navigation', () => {
@@ -196,7 +200,9 @@ test.describe.skip('Queue sidebar', () => {
delay: 256
})
await comfyPage.nextFrame()
expect(comfyPage.menu.queueTab.getGalleryImage(end)).toBeVisible()
await expect(
comfyPage.menu.queueTab.getGalleryImage(end)
).toBeVisible()
})
})
})

View File

@@ -1,4 +1,5 @@
import { Page, expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { SystemStats } from '../../src/schemas/apiSchema'
import type { SystemStats } from '../../src/schemas/apiSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Version Mismatch Warnings', () => {

View File

@@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
"extends": "../tsconfig.json",
"compilerOptions": {
/* Test files should not be compiled */
"noEmit": true,
@@ -9,13 +9,6 @@
"resolveJsonModule": true
},
"include": [
"*.ts",
"*.mts",
"*.config.js",
"browser_tests/**/*.ts",
"scripts/**/*.js",
"scripts/**/*.ts",
"tests-ui/**/*.ts",
".storybook/**/*.ts"
"**/*.ts",
]
}

View File

@@ -153,5 +153,14 @@ export default defineConfig([
}
]
}
},
{
files: ['tests-ui/**/*'],
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false }
]
}
}
])

View File

@@ -27,8 +27,7 @@ const config: KnipConfig = {
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Staged for for use with subgraph widget promotion
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts',
'src/core/graph/operations/types.ts'
'src/lib/litegraph/src/widgets/DisconnectedWidget.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.27.5",
"version": "1.28.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -2,6 +2,7 @@ import * as fs from 'fs'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
import type { FormItem, SettingParams } from '../src/platform/settings/types'
import type { ComfyCommandImpl } from '../src/stores/commandStore'
@@ -131,6 +132,23 @@ test('collect-i18n-general', async ({ comfyPage }) => {
])
)
// Desktop Dialogs
const allDesktopDialogsLocale = Object.fromEntries(
Object.values(DESKTOP_DIALOGS).map((dialog) => [
normalizeI18nKey(dialog.id),
{
title: dialog.title,
message: dialog.message,
buttons: Object.fromEntries(
dialog.buttons.map((button) => [
normalizeI18nKey(button.label),
button.label
])
)
}
])
)
fs.writeFileSync(
localePath,
JSON.stringify(
@@ -144,7 +162,8 @@ test('collect-i18n-general', async ({ comfyPage }) => {
...allSettingCategoriesLocale
},
serverConfigItems: allServerConfigsLocale,
serverConfigCategories: allServerConfigCategoriesLocale
serverConfigCategories: allServerConfigCategoriesLocale,
desktopDialogs: allDesktopDialogsLocale
},
null,
2

View File

@@ -66,6 +66,8 @@
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-stone-100: #444444;
--color-stone-200: #828282;
--color-stone-300: #bbbbbb;
@@ -103,6 +105,10 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
--color-bypass: #6a246a;
--color-error: #962a2a;

View File

@@ -10,7 +10,7 @@
class="absolute inset-0"
/>
<img
v-show="isImageLoaded"
v-if="cachedSrc"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
@@ -77,8 +77,8 @@ const shouldLoad = computed(() => isIntersecting.value)
watch(
shouldLoad,
async (shouldLoad) => {
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
async (shouldLoadVal) => {
if (shouldLoadVal && src && !cachedSrc.value && !hasError.value) {
try {
const cachedMedia = await getCachedMedia(src)
if (cachedMedia.error) {
@@ -93,7 +93,7 @@ watch(
console.warn('Failed to load cached media:', error)
cachedSrc.value = src
}
} else if (!shouldLoad) {
} else if (!shouldLoadVal) {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}

View File

@@ -96,7 +96,6 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -118,6 +117,7 @@ import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'

View File

@@ -124,11 +124,11 @@ import ButtonGroup from 'primevue/buttongroup'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useZoomControls } from '@/composables/useZoomControls'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -5,12 +5,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
// Mock the composables and services
vi.mock('@/composables/graph/useCanvasInteractions', () => ({
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
handleWheel: vi.fn()
}))

View File

@@ -60,9 +60,9 @@ import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButt
import RefreshSelectionButton from '@/components/graph/selectionToolbox/RefreshSelectionButton.vue'
import PublishSubgraphButton from '@/components/graph/selectionToolbox/SaveToSubgraphLibrary.vue'
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'

View File

@@ -1,4 +1,4 @@
import { onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
@@ -170,50 +170,75 @@ export function useSelectionToolboxPosition(
}
)
// Watch for dragging state
watch(
() => canvasStore.canvas?.state?.draggingItems,
(dragging) => {
if (dragging) {
visible.value = false
if (moreOptionsOpen.value) {
const currentSig = buildSelectionSignature(canvasStore)
if (currentSig !== moreOptionsSelectionSignature) {
moreOptionsSelectionSignature = null
}
moreOptionsWasOpenBeforeDrag = true
moreOptionsOpen.value = false
moreOptionsRestorePending.value = !!moreOptionsSelectionSignature
if (moreOptionsRestorePending.value) {
forceCloseMoreOptionsSignal.value++
} else {
moreOptionsWasOpenBeforeDrag = false
}
} else {
moreOptionsRestorePending.value = false
moreOptionsWasOpenBeforeDrag = false
}
} else {
requestAnimationFrame(() => {
updateSelectionBounds()
const selectionMatches = currentSelectionMatchesSignature(canvasStore)
const shouldRestore =
moreOptionsWasOpenBeforeDrag &&
visible.value &&
moreOptionsRestorePending.value &&
selectionMatches
if (shouldRestore) {
restoreMoreOptionsSignal.value++
} else {
moreOptionsRestorePending.value = false
}
moreOptionsWasOpenBeforeDrag = false
})
}
const handleDragStateChange = (dragging: boolean) => {
if (dragging) {
handleDragStart()
return
}
)
handleDragEnd()
}
const handleDragStart = () => {
visible.value = false
// Early return if more options wasn't open
if (!moreOptionsOpen.value) {
moreOptionsRestorePending.value = false
moreOptionsWasOpenBeforeDrag = false
return
}
// Handle more options cleanup
const currentSig = buildSelectionSignature(canvasStore)
const selectionChanged = currentSig !== moreOptionsSelectionSignature
if (selectionChanged) {
moreOptionsSelectionSignature = null
}
moreOptionsOpen.value = false
moreOptionsWasOpenBeforeDrag = true
moreOptionsRestorePending.value = !!moreOptionsSelectionSignature
if (moreOptionsRestorePending.value) {
forceCloseMoreOptionsSignal.value++
return
}
moreOptionsWasOpenBeforeDrag = false
}
const handleDragEnd = () => {
requestAnimationFrame(() => {
updateSelectionBounds()
const selectionMatches = currentSelectionMatchesSignature(canvasStore)
const shouldRestore =
moreOptionsWasOpenBeforeDrag &&
visible.value &&
moreOptionsRestorePending.value &&
selectionMatches
// Single point of assignment for each ref
moreOptionsRestorePending.value =
shouldRestore && moreOptionsRestorePending.value
moreOptionsWasOpenBeforeDrag = false
if (shouldRestore) {
restoreMoreOptionsSignal.value++
}
})
}
// Unified dragging state - combines both LiteGraph and Vue node dragging
const isDragging = computed((): boolean => {
const litegraphDragging = canvasStore.canvas?.state?.draggingItems ?? false
const vueNodeDragging =
shouldRenderVueNodes.value && layoutStore.isDraggingVueNodes.value
return litegraphDragging || vueNodeDragging
})
watch(isDragging, handleDragStateChange)
onUnmounted(() => {
resetMoreOptionsState()

View File

@@ -1,5 +1,5 @@
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { fitDimensionsToNodeWidth } from '@/utils/imageUtil'

View File

@@ -42,7 +42,12 @@ export function useManagerState() {
)
// Check command line args first (highest priority)
if (systemStats.value?.system?.argv?.includes('--disable-manager')) {
// --enable-manager flag enables the manager (opposite of old --disable-manager)
const hasEnableManager =
systemStats.value?.system?.argv?.includes('--enable-manager')
// If --enable-manager is NOT present, manager is disabled
if (!hasEnableManager) {
return ManagerUIState.DISABLED
}

View File

@@ -0,0 +1,75 @@
export interface DialogAction {
readonly label: string
readonly action: 'openUrl' | 'close' | 'cancel'
readonly url?: string
readonly severity?: 'danger' | 'primary' | 'secondary' | 'warn'
readonly returnValue: string
}
interface DesktopDialog {
readonly title: string
readonly message: string
readonly buttons: DialogAction[]
}
export const DESKTOP_DIALOGS = {
/** Shown when a corrupt venv is detected. */
reinstallVenv: {
title: 'Reinstall ComfyUI (Fresh Start)?',
message: `Sorry, we can't launch ComfyUI because some installed packages aren't compatible.
Click Reinstall to restore ComfyUI and get back up and running.
Please note: if you've added custom nodes, you'll need to reinstall them after this process.`,
buttons: [
{
label: 'Learn More',
action: 'openUrl',
url: 'https://docs.comfy.org',
returnValue: 'openDocs'
},
{
label: 'Reinstall',
action: 'close',
severity: 'danger',
returnValue: 'resetVenv'
}
]
},
/** A dialog that is shown when an invalid dialog ID is provided. */
invalidDialog: {
title: 'Invalid Dialog',
message: `Invalid dialog ID was provided.`,
buttons: [
{
label: 'Close',
action: 'cancel',
returnValue: 'cancel'
}
]
}
} as const satisfies { [K: string]: DesktopDialog }
/** The ID of a desktop dialog. */
type DesktopDialogId = keyof typeof DESKTOP_DIALOGS
/**
* Checks if {@link id} is a valid dialog ID.
* @param id The string to check
* @returns `true` if the ID is a valid dialog ID, otherwise `false`
*/
function isDialogId(id: unknown): id is DesktopDialogId {
return typeof id === 'string' && id in DESKTOP_DIALOGS
}
/**
* Gets the dialog with the given ID.
* @param dialogId The ID of the dialog to get
* @returns The dialog with the given ID
*/
export function getDialog(
dialogId: string | string[]
): DesktopDialog & { id: DesktopDialogId } {
const id = isDialogId(dialogId) ? dialogId : 'invalidDialog'
return { id, ...structuredClone(DESKTOP_DIALOGS[id]) }
}

View File

@@ -1,532 +0,0 @@
# GraphMutationService Design and Implementation
## Overview
GraphMutationService is the centralized service layer for all graph modification operations in ComfyUI Frontend. It provides a unified, command-based API for graph mutations with built-in error handling through the Result pattern, serving as the single entry point for all graph modification operations.
## Project Background
### Current System Analysis
ComfyUI Frontend uses the LiteGraph library for graph operations, with main components including:
1. **LGraph** (`src/lib/litegraph/src/LGraph.ts`)
- Core graph management class
- Provides basic operations like `add()`, `remove()`
- Supports `beforeChange()`/`afterChange()` transaction mechanism
2. **LGraphNode** (`src/lib/litegraph/src/LGraphNode.ts`)
- Node class containing position, connections, and other properties
- Provides methods like `connect()`, `disconnectInput()`, `disconnectOutput()`
3. **ChangeTracker** (`src/scripts/changeTracker.ts`)
- Existing undo/redo system
- Snapshot-based history tracking
- Supports up to 50 history states
**Primary Goals:**
- Single entry point for all graph modifications via command pattern
- Built-in validation and error handling through Result pattern
- Transaction support for atomic operations
- Natural undo/redo through existing ChangeTracker
- Clean architecture for future extensibility (CRDT support ready)
- Comprehensive error context preservation
## Architecture Patterns
### Command Pattern
All operations are executed through a unified command interface:
```typescript
interface GraphMutationOperation {
type: string // Operation type identifier
timestamp: number // For ordering and CRDT support
origin: CommandOrigin // Source of the command
params?: any // Operation-specific parameters
}
```
### Result Pattern
All operations return a discriminated union Result type instead of throwing exceptions:
```typescript
type Result<T, E> =
| { success: true; data: T }
| { success: false; error: E }
```
### Error Handling
Custom error class with rich context:
```typescript
class GraphMutationError extends Error {
code: string
context: Record<string, any> // Contains operation details and original error
}
```
### Interface-Based Architecture
The GraphMutationService follows an **interface-based design pattern** with singleton state management:
- **IGraphMutationService Interface**: Defines the complete contract for all graph operations
- **GraphMutationService Class**: Implements the interface with LiteGraph integration
- **Singleton State**: Shared clipboard and transaction state across components
```typescript
interface IGraphMutationService {
// Central command dispatcher
applyOperation(
operation: GraphMutationOperation
): Promise<Result<any, GraphMutationError>>
// Direct operation methods (all return Result types)
createNode(params: createNodeParams): Promise<Result<NodeId, GraphMutationError>>
removeNode(nodeId: NodeId): Promise<Result<void, GraphMutationError>>
// ... 40+ total operations
// Undo/Redo
undo(): Promise<Result<void, GraphMutationError>>
redo(): Promise<Result<void, GraphMutationError>>
}
```
### Core Components
```typescript
// Implementation Class
class GraphMutationService implements IGraphMutationService {
private workflowStore = useWorkflowStore()
private static readonly CLIPBOARD_KEY = 'litegrapheditor_clipboard'
// Command dispatcher
async applyOperation(
operation: GraphMutationOperation
): Promise<Result<any, GraphMutationError>> {
switch (operation.type) {
case 'createNode':
return await this.createNode(operation.params)
case 'removeNode':
return await this.removeNode(operation.params)
// ... handle all operation types
default:
return {
success: false,
error: new GraphMutationError('Unknown operation type', {
operation: operation.type
})
}
}
}
// All operations wrapped with error handling
async createNode(params: createNodeParams): Promise<Result<NodeId, GraphMutationError>> {
try {
const graph = this.getGraph()
graph.beforeChange()
// ... perform operation
graph.afterChange()
return { success: true, data: nodeId }
} catch (error) {
return {
success: false,
error: new GraphMutationError('Failed to create node', {
operation: 'createNode',
params,
cause: error
})
}
}
}
}
// Singleton Hook
export const useGraphMutationService = (): IGraphMutationService => {
if (!graphMutationServiceInstance) {
graphMutationServiceInstance = new GraphMutationService()
}
return graphMutationServiceInstance
}
```
## Implemented Operations
### Node Operations (8 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `createNode` | Create a new node in the graph | `Result<NodeId, GraphMutationError>` |
| `removeNode` | Remove a node from the graph | `Result<void, GraphMutationError>` |
| `updateNodeProperty` | Update a custom node property | `Result<void, GraphMutationError>` |
| `updateNodeTitle` | Change the node's title | `Result<void, GraphMutationError>` |
| `changeNodeMode` | Change execution mode (ALWAYS/BYPASS/etc) | `Result<void, GraphMutationError>` |
| `cloneNode` | Create a copy of a node | `Result<NodeId, GraphMutationError>` |
| `bypassNode` | Set node to bypass mode | `Result<void, GraphMutationError>` |
| `unbypassNode` | Remove bypass mode from node | `Result<void, GraphMutationError>` |
### Connection Operations (3 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `connect` | Create a connection between nodes | `Result<LinkId, GraphMutationError>` |
| `disconnect` | Disconnect a node input/output slot | `Result<boolean, GraphMutationError>` |
| `disconnectLink` | Disconnect by link ID | `Result<void, GraphMutationError>` |
### Group Operations (5 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `createGroup` | Create a new node group | `Result<GroupId, GraphMutationError>` |
| `removeGroup` | Delete a group (nodes remain) | `Result<void, GraphMutationError>` |
| `updateGroupTitle` | Change group title | `Result<void, GraphMutationError>` |
| `addNodesToGroup` | Add nodes to group and auto-resize | `Result<void, GraphMutationError>` |
| `recomputeGroupNodes` | Recalculate which nodes are in group | `Result<void, GraphMutationError>` |
### Clipboard Operations (3 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `copyNodes` | Copy nodes to clipboard | `Result<void, GraphMutationError>` |
| `cutNodes` | Cut nodes to clipboard | `Result<void, GraphMutationError>` |
| `pasteNodes` | Paste nodes from clipboard | `Result<NodeId[], GraphMutationError>` |
### Reroute Operations (2 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `addReroute` | Add a reroute point on a connection | `Result<RerouteId, GraphMutationError>` |
| `removeReroute` | Remove a reroute point | `Result<void, GraphMutationError>` |
### Subgraph Operations (10 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `createSubgraph` | Create a subgraph from selected items | `Result<{subgraph, node}, GraphMutationError>` |
| `unpackSubgraph` | Unpack a subgraph node back into regular nodes | `Result<void, GraphMutationError>` |
| `addSubgraphNodeInput` | Add input slot to subgraph node | `Result<number, GraphMutationError>` |
| `addSubgraphNodeOutput` | Add output slot to subgraph node | `Result<number, GraphMutationError>` |
| `removeSubgraphNodeInput` | Remove input slot from subgraph node | `Result<void, GraphMutationError>` |
| `removeSubgraphNodeOutput` | Remove output slot from subgraph node | `Result<void, GraphMutationError>` |
| `addSubgraphInput` | Add an input to a subgraph | `Result<void, GraphMutationError>` |
| `addSubgraphOutput` | Add an output to a subgraph | `Result<void, GraphMutationError>` |
| `removeSubgraphInput` | Remove a subgraph input | `Result<void, GraphMutationError>` |
| `removeSubgraphOutput` | Remove a subgraph output | `Result<void, GraphMutationError>` |
### Graph-level Operations (1 operation)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `clearGraph` | Clear all nodes and connections | `Result<void, GraphMutationError>` |
### History Operations (2 operations)
| Operation | Description | Result Type |
|-----------|-------------|-------------|
| `undo` | Undo the last operation | `Result<void, GraphMutationError>` |
| `redo` | Redo the previously undone operation | `Result<void, GraphMutationError>` |
## Usage Examples
### Command Pattern Usage
```typescript
import { useGraphMutationService, CommandOrigin } from '@/core/graph/operations'
import type { GraphMutationOperation } from '@/core/graph/operations/types'
const service = useGraphMutationService()
// Execute operations via command pattern
const operation: GraphMutationOperation = {
type: 'createNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
type: 'LoadImage',
title: 'My Image Loader',
properties: { seed: 12345 }
}
}
const result = await service.applyOperation(operation)
if (result.success) {
console.log('Node created with ID:', result.data)
} else {
console.error('Failed:', result.error.message)
console.error('Context:', result.error.context)
}
```
### Direct Method Usage with Result Pattern
```typescript
// All methods return Result<T, GraphMutationError>
const result = await service.createNode({
type: 'LoadImage',
title: 'Image Loader'
})
if (result.success) {
const nodeId = result.data
// Update node properties
const updateResult = await service.updateNodeProperty({
nodeId,
property: 'seed',
value: 12345
})
if (!updateResult.success) {
console.error('Update failed:', updateResult.error)
}
} else {
// Access detailed error context
const { operation, params, cause } = result.error.context
console.error(`Operation ${operation} failed:`, cause)
}
```
### Connection Management
```typescript
// Create a connection
const connectOp: GraphMutationOperation = {
type: 'connect',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
sourceNodeId: node1Id,
sourceSlot: 0,
targetNodeId: node2Id,
targetSlot: 0
}
}
const result = await service.applyOperation(connectOp)
if (result.success) {
const linkId = result.data
// Later disconnect by link ID
const disconnectResult = await service.disconnectLink(linkId)
if (!disconnectResult.success) {
console.error('Disconnect failed:', disconnectResult.error)
}
}
```
### Group Management
```typescript
// Create a group via command
const createGroupOp: GraphMutationOperation = {
type: 'createGroup',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
title: 'Image Processing',
size: [400, 300],
color: '#335577'
}
}
const groupResult = await service.applyOperation(createGroupOp)
if (groupResult.success) {
const groupId = groupResult.data
// Add nodes to group
const addNodesResult = await service.addNodesToGroup({
groupId,
nodeIds: [node1Id, node2Id]
})
if (!addNodesResult.success) {
console.error('Failed to add nodes:', addNodesResult.error)
}
}
```
### Clipboard Operations
```typescript
// Copy nodes
const copyResult = await service.copyNodes([node1Id, node2Id])
if (copyResult.success) {
// Paste at a different location
const pasteResult = await service.pasteNodes()
if (pasteResult.success) {
console.log('Pasted nodes:', pasteResult.data)
} else {
console.error('Paste failed:', pasteResult.error)
}
}
// Cut operation
const cutResult = await service.cutNodes([node3Id])
// Original nodes marked for deletion after paste
```
### Error Context Preservation
```typescript
const result = await service.updateNodeProperty({
nodeId: 'invalid-node',
property: 'seed',
value: 12345
})
if (!result.success) {
// Rich error context available
console.error('Error:', result.error.message)
console.error('Code:', result.error.code)
console.error('Operation:', result.error.context.operation)
console.error('Parameters:', result.error.context.params)
console.error('Original error:', result.error.context.cause)
}
```
### Subgraph Operations
```typescript
// Create subgraph from selected items
const subgraphOp: GraphMutationOperation = {
type: 'createSubgraph',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
selectedItems: new Set([node1, node2, node3])
}
}
const result = await service.applyOperation(subgraphOp)
if (result.success) {
const { subgraph, node } = result.data
// Add I/O to subgraph
await service.addSubgraphInput({
subgraphId: subgraph.id,
name: 'image',
type: 'IMAGE'
})
}
```
### History Operations
```typescript
// All operations are undoable
const result = await service.undo()
if (result.success) {
console.log('Undo successful')
} else {
console.error('Undo failed:', result.error.message)
// Might fail if no history or change tracker unavailable
}
// Redo
const redoResult = await service.redo()
```
## Implementation Details
### Integration Points
1. **LiteGraph Integration**
- Uses `app.graph` for graph access
- Calls `beforeChange()`/`afterChange()` for all mutations
- Integrates with existing LiteGraph node/connection APIs
2. **ChangeTracker Integration**
- Maintains compatibility with existing undo/redo system
- Transactions wrapped with `beforeChange()`/`afterChange()`
- No longer calls `checkState()` directly (removed from new implementation)
3. **Error Handling**
- All operations wrapped in try-catch blocks
- Errors converted to GraphMutationError with context
- Original errors preserved in context.cause
## Technical Decisions
### Why Command Pattern?
- **Uniformity**: Single entry point for all operations
- **Extensibility**: Easy to add new operations
- **CRDT Ready**: Commands include timestamp and origin for future sync
- **Testing**: Easy to test command dispatch and execution
### Why Result Pattern?
- **Explicit Error Handling**: Forces consumers to handle errors
- **No Exceptions**: Predictable control flow
- **Rich Context**: Errors carry operation context
- **Type Safety**: TypeScript discriminated unions
### Why GraphMutationError?
- **Context Preservation**: Maintains full operation context
- **Debugging**: Detailed information for troubleshooting
- **Standardization**: Consistent error structure
- **Traceability**: Links errors to specific operations
## Related Files
- **Interface Definition**: `src/core/graph/operations/IGraphMutationService.ts`
- **Implementation**: `src/core/graph/operations/graphMutationService.ts`
- **Types**: `src/core/graph/operations/types.ts`
- **Error Class**: `src/core/graph/operations/GraphMutationError.ts`
- **Tests**: `tests-ui/tests/services/graphMutationService.test.ts`
- **LiteGraph Core**: `src/lib/litegraph/src/LGraph.ts`
- **Node Implementation**: `src/lib/litegraph/src/LGraphNode.ts`
- **Change Tracking**: `src/scripts/changeTracker.ts`
## Implementation Compatibility Notes
### Critical Implementation Details to Maintain:
1. **beforeChange/afterChange Pattern**
- All mutations MUST be wrapped with `graph.beforeChange()` and `graph.afterChange()`
- This enables undo/redo functionality through ChangeTracker
- Reference: Pattern used consistently throughout service
2. **Node ID Management**
- Node IDs use NodeId type from schemas
- Custom IDs can be provided during creation (for workflow loading)
3. **Clipboard Implementation**
- Uses localStorage with key 'litegrapheditor_clipboard'
- Maintains node connections during copy/paste
- Cut operation marks nodes for deletion after paste
4. **Group Management**
- Groups auto-resize when adding nodes using `recomputeInsideNodes()`
- Visual operations call `graph.setDirtyCanvas(true, false)`
5. **Error Handling**
- All operations return Result<T, GraphMutationError>
- Never throw exceptions from public methods
- Preserve original error in context.cause
6. **Subgraph Support**
- Uses instanceof checks for SubgraphNode detection
- Iterates through graph._nodes to find subgraphs
## Migration Strategy
1. Replace direct graph method calls with service operations
2. Update error handling from try-catch to Result pattern checking
3. Convert operation calls to use command pattern where beneficial
4. Leverage error context for better debugging
5. Ensure all operations maintain existing beforeChange/afterChange patterns
## Important Notes
1. **Always use GraphMutationService** - Never call graph methods directly
2. **Handle Result types** - Check success before using data
3. **Preserve error context** - Log full error context for debugging
4. **Command pattern ready** - Can easily add CRDT sync in future
5. **Performance** - Result pattern and command recording have minimal overhead
6. **Type safety** - Use TypeScript types for all operations

View File

@@ -1,14 +0,0 @@
export class GraphMutationError extends Error {
public readonly code: string
public readonly context: Record<string, any>
constructor(
message: string,
context: Record<string, any>,
code = 'GRAPH_MUTATION_ERROR'
) {
super(message)
this.code = code
this.context = context
}
}

View File

@@ -1,144 +0,0 @@
import type { GraphMutationError } from '@/core/graph/operations/GraphMutationError'
import type {
AddNodeInputParams,
AddNodeOutputParams,
AddNodesToGroupParams,
AddRerouteParams,
ChangeNodeModeParams,
ConnectParams,
CreateGroupParams,
CreateNodeParams,
CreateSubgraphParams,
CreateSubgraphResult,
DisconnectParams,
GraphMutationOperation,
NodeInputSlotParams,
OperationResultType,
Result,
SubgraphIndexParams,
SubgraphNameTypeParams,
UpdateGroupTitleParams,
UpdateNodePropertyParams,
UpdateNodeTitleParams
} from '@/core/graph/operations/types'
import type { GroupId } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
export interface IGraphMutationService {
applyOperation<T extends GraphMutationOperation>(
operation: T
): Promise<Result<OperationResultType<T>, GraphMutationError>>
createNode(
params: CreateNodeParams
): Promise<Result<NodeId, GraphMutationError>>
getNodeById(nodeId: NodeId): Promise<Result<LGraphNode, GraphMutationError>>
removeNode(nodeId: NodeId): Promise<Result<void, GraphMutationError>>
updateNodeProperty(
params: UpdateNodePropertyParams
): Promise<Result<void, GraphMutationError>>
updateNodeTitle(
params: UpdateNodeTitleParams
): Promise<Result<void, GraphMutationError>>
changeNodeMode(
params: ChangeNodeModeParams
): Promise<Result<void, GraphMutationError>>
cloneNode(nodeId: NodeId): Promise<Result<NodeId, GraphMutationError>>
connect(params: ConnectParams): Promise<Result<LinkId, GraphMutationError>>
disconnect(
params: DisconnectParams
): Promise<Result<boolean, GraphMutationError>>
disconnectLink(linkId: LinkId): Promise<Result<void, GraphMutationError>>
createGroup(
params: CreateGroupParams
): Promise<Result<GroupId, GraphMutationError>>
removeGroup(groupId: GroupId): Promise<Result<void, GraphMutationError>>
updateGroupTitle(
params: UpdateGroupTitleParams
): Promise<Result<void, GraphMutationError>>
addNodesToGroup(
params: AddNodesToGroupParams
): Promise<Result<void, GraphMutationError>>
recomputeGroupNodes(
groupId: GroupId
): Promise<Result<void, GraphMutationError>>
addReroute(
params: AddRerouteParams
): Promise<Result<RerouteId, GraphMutationError>>
removeReroute(rerouteId: RerouteId): Promise<Result<void, GraphMutationError>>
copyNodes(nodeIds: NodeId[]): Promise<Result<void, GraphMutationError>>
cutNodes(nodeIds: NodeId[]): Promise<Result<void, GraphMutationError>>
pasteNodes(): Promise<Result<NodeId[], GraphMutationError>>
addSubgraphNodeInput(
params: AddNodeInputParams
): Promise<Result<number, GraphMutationError>>
addSubgraphNodeOutput(
params: AddNodeOutputParams
): Promise<Result<number, GraphMutationError>>
removeSubgraphNodeInput(
params: NodeInputSlotParams
): Promise<Result<void, GraphMutationError>>
removeSubgraphNodeOutput(
params: NodeInputSlotParams
): Promise<Result<void, GraphMutationError>>
createSubgraph(
params: CreateSubgraphParams
): Promise<Result<CreateSubgraphResult, GraphMutationError>>
unpackSubgraph(
subgraphNodeId: NodeId
): Promise<Result<void, GraphMutationError>>
addSubgraphInput(
params: SubgraphNameTypeParams
): Promise<Result<void, GraphMutationError>>
addSubgraphOutput(
params: SubgraphNameTypeParams
): Promise<Result<void, GraphMutationError>>
removeSubgraphInput(
params: SubgraphIndexParams
): Promise<Result<void, GraphMutationError>>
removeSubgraphOutput(
params: SubgraphIndexParams
): Promise<Result<void, GraphMutationError>>
clearGraph(): Promise<Result<void, GraphMutationError>>
bypassNode(nodeId: NodeId): Promise<Result<void, GraphMutationError>>
unbypassNode(nodeId: NodeId): Promise<Result<void, GraphMutationError>>
undo(): Promise<Result<void, GraphMutationError>>
redo(): Promise<Result<void, GraphMutationError>>
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,367 +0,0 @@
/**
* Graph Mutation Command System - Type Definitions
*
* Defines command types for graph mutation operations with CRDT support.
* Each command represents an atomic operation that can be applied, undone, and synchronized.
*/
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
import type { GroupId, LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type {
SubgraphId,
SubgraphNode
} from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
export type Result<T, E> =
| { success: true; data: T }
| { success: false; error: E }
export interface CreateNodeParams {
type: string
properties?: Record<string, string | number | boolean | object>
title?: string
}
export interface UpdateNodePropertyParams {
nodeId: NodeId
property: string
value: string | number | boolean | object | string[] | undefined
}
export interface UpdateNodeTitleParams {
nodeId: NodeId
title: string
}
export interface ChangeNodeModeParams {
nodeId: NodeId
mode: number
}
export interface ConnectParams {
sourceNodeId: NodeId
sourceSlot: number | string
targetNodeId: NodeId
targetSlot: number | string
}
export interface DisconnectParams {
nodeId: NodeId
slot: number | string
slotType: 'input' | 'output'
targetNodeId?: NodeId
}
export interface CreateGroupParams {
title?: string
size?: [number, number]
color?: string
fontSize?: number
}
export interface UpdateGroupTitleParams {
groupId: GroupId
title: string
}
export interface AddNodesToGroupParams {
groupId: GroupId
nodeIds: NodeId[]
}
export interface AddRerouteParams {
pos: [number, number]
linkId?: LinkId
parentRerouteId?: RerouteId
}
export interface AddNodeInputParams {
nodeId: NodeId
name: string
type: string
extra_info?: Record<string, unknown>
}
export interface AddNodeOutputParams {
nodeId: NodeId
name: string
type: string
extra_info?: Record<string, unknown>
}
export interface CreateSubgraphParams {
selectedItems: Set<LGraphNode | LGraphGroup>
}
export interface NodeInputSlotParams {
nodeId: NodeId
slot: number
}
export interface SubgraphNameTypeParams {
subgraphId: SubgraphId
name: string
type: string
}
export interface SubgraphIndexParams {
subgraphId: SubgraphId
index: number
}
export enum CommandOrigin {
Local = 'local'
}
export type GraphMutationOperation =
| CreateNodeCommand
| RemoveNodeCommand
| UpdateNodePropertyCommand
| UpdateNodeTitleCommand
| ChangeNodeModeCommand
| CloneNodeCommand
| BypassNodeCommand
| UnbypassNodeCommand
| ConnectCommand
| DisconnectCommand
| DisconnectLinkCommand
| CreateGroupCommand
| RemoveGroupCommand
| UpdateGroupTitleCommand
| AddNodesToGroupCommand
| RecomputeGroupNodesCommand
| AddRerouteCommand
| RemoveRerouteCommand
| CopyNodesCommand
| CutNodesCommand
| PasteNodesCommand
| CreateSubgraphCommand
| UnpackSubgraphCommand
| AddSubgraphNodeInputCommand
| AddSubgraphNodeOutputCommand
| RemoveSubgraphNodeInputCommand
| RemoveSubgraphNodeOutputCommand
| AddSubgraphInputCommand
| AddSubgraphOutputCommand
| RemoveSubgraphInputCommand
| RemoveSubgraphOutputCommand
| ClearGraphCommand
| UndoCommand
| RedoCommand
interface GraphOpBase {
/** Timestamp for ordering commands */
timestamp: number
/** Origin of the command */
origin: CommandOrigin
}
export interface CreateNodeCommand extends GraphOpBase {
type: 'createNode'
params: CreateNodeParams
}
export interface RemoveNodeCommand extends GraphOpBase {
type: 'removeNode'
params: NodeId
}
export interface UpdateNodePropertyCommand extends GraphOpBase {
type: 'updateNodeProperty'
params: UpdateNodePropertyParams
}
export interface UpdateNodeTitleCommand extends GraphOpBase {
type: 'updateNodeTitle'
params: UpdateNodeTitleParams
}
export interface ChangeNodeModeCommand extends GraphOpBase {
type: 'changeNodeMode'
params: ChangeNodeModeParams
}
export interface CloneNodeCommand extends GraphOpBase {
type: 'cloneNode'
params: NodeId
}
export interface BypassNodeCommand extends GraphOpBase {
type: 'bypassNode'
params: NodeId
}
export interface UnbypassNodeCommand extends GraphOpBase {
type: 'unbypassNode'
params: NodeId
}
export interface ConnectCommand extends GraphOpBase {
type: 'connect'
params: ConnectParams
}
export interface DisconnectCommand extends GraphOpBase {
type: 'disconnect'
params: DisconnectParams
}
export interface DisconnectLinkCommand extends GraphOpBase {
type: 'disconnectLink'
params: LinkId
}
export interface CreateGroupCommand extends GraphOpBase {
type: 'createGroup'
params: CreateGroupParams
}
export interface RemoveGroupCommand extends GraphOpBase {
type: 'removeGroup'
params: GroupId
}
export interface UpdateGroupTitleCommand extends GraphOpBase {
type: 'updateGroupTitle'
params: UpdateGroupTitleParams
}
export interface AddNodesToGroupCommand extends GraphOpBase {
type: 'addNodesToGroup'
params: AddNodesToGroupParams
}
export interface RecomputeGroupNodesCommand extends GraphOpBase {
type: 'recomputeGroupNodes'
params: GroupId
}
// Reroute Commands
export interface AddRerouteCommand extends GraphOpBase {
type: 'addReroute'
params: AddRerouteParams
}
export interface RemoveRerouteCommand extends GraphOpBase {
type: 'removeReroute'
params: RerouteId
}
export interface CopyNodesCommand extends GraphOpBase {
type: 'copyNodes'
nodeIds: NodeId[]
}
export interface CutNodesCommand extends GraphOpBase {
type: 'cutNodes'
params: NodeId[]
}
export interface PasteNodesCommand extends GraphOpBase {
type: 'pasteNodes'
}
export interface CreateSubgraphCommand extends GraphOpBase {
type: 'createSubgraph'
params: CreateSubgraphParams
}
export interface UnpackSubgraphCommand extends GraphOpBase {
type: 'unpackSubgraph'
params: NodeId
}
export interface AddSubgraphNodeInputCommand extends GraphOpBase {
type: 'addSubgraphNodeInput'
params: AddNodeInputParams
}
export interface AddSubgraphNodeOutputCommand extends GraphOpBase {
type: 'addSubgraphNodeOutput'
params: AddNodeOutputParams
}
export interface RemoveSubgraphNodeInputCommand extends GraphOpBase {
type: 'removeSubgraphNodeInput'
params: NodeInputSlotParams
}
export interface RemoveSubgraphNodeOutputCommand extends GraphOpBase {
type: 'removeSubgraphNodeOutput'
params: NodeInputSlotParams
}
export interface AddSubgraphInputCommand extends GraphOpBase {
type: 'addSubgraphInput'
params: SubgraphNameTypeParams
}
export interface AddSubgraphOutputCommand extends GraphOpBase {
type: 'addSubgraphOutput'
params: SubgraphNameTypeParams
}
export interface RemoveSubgraphInputCommand extends GraphOpBase {
type: 'removeSubgraphInput'
params: SubgraphIndexParams
}
export interface RemoveSubgraphOutputCommand extends GraphOpBase {
type: 'removeSubgraphOutput'
params: SubgraphIndexParams
}
export interface ClearGraphCommand extends GraphOpBase {
type: 'clearGraph'
}
export interface UndoCommand extends GraphOpBase {
type: 'undo'
}
export interface RedoCommand extends GraphOpBase {
type: 'redo'
}
export type NodeIdReturnOperations = CreateNodeCommand | CloneNodeCommand
export type LinkIdReturnOperations = ConnectCommand
export type BooleanReturnOperations = DisconnectCommand
export type GroupIdReturnOperations = CreateGroupCommand
export type RerouteIdReturnOperations = AddRerouteCommand
export type NodeIdArrayReturnOperations = PasteNodesCommand
export type NumberReturnOperations =
| AddSubgraphNodeInputCommand
| AddSubgraphNodeOutputCommand
export interface CreateSubgraphResult {
subgraph: Subgraph
node: SubgraphNode
}
export type OperationResultType<T extends GraphMutationOperation> =
T extends NodeIdReturnOperations
? NodeId
: T extends LinkIdReturnOperations
? LinkId
: T extends BooleanReturnOperations
? boolean
: T extends GroupIdReturnOperations
? GroupId
: T extends RerouteIdReturnOperations
? RerouteId
: T extends NodeIdArrayReturnOperations
? NodeId[]
: T extends NumberReturnOperations
? number
: T extends CreateSubgraphCommand
? CreateSubgraphResult
: void

View File

@@ -24,8 +24,6 @@ import {
} from './measure'
import type { ISerialisedGroup } from './types/serialisation'
export type GroupId = number
export interface IGraphGroupFlags extends Record<string, unknown> {
pinned?: true
}

View File

@@ -4,7 +4,10 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink, type ResolvedConnection } from '@/lib/litegraph/src/LLink'
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
import type { ISubgraphInput } from '@/lib/litegraph/src/interfaces'
import type {
ISubgraphInput,
IWidgetLocator
} from '@/lib/litegraph/src/interfaces'
import type {
INodeInputSlot,
ISlotType,
@@ -31,8 +34,6 @@ import {
} from './ExecutableNodeDTO'
import type { SubgraphInput } from './SubgraphInput'
export type SubgraphId = string
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
*/
@@ -80,9 +81,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const existingInput = this.inputs.find((i) => i.name == name)
if (existingInput) {
const linkId = subgraphInput.linkIds[0]
const { inputNode } = subgraph.links[linkId].resolve(subgraph)
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
const widget = inputNode?.widgets?.find?.((w) => w.name == name)
if (widget) this.#setWidget(subgraphInput, existingInput, widget)
if (widget)
this.#setWidget(subgraphInput, existingInput, widget, input?.widget)
return
}
const input = this.addInput(name, type)
@@ -187,13 +189,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-connected',
() => {
(e) => {
if (input._widget) return
const widget = subgraphInput._widget
if (!widget) return
this.#setWidget(subgraphInput, input, widget)
const widgetLocator = e.detail.input.widget
this.#setWidget(subgraphInput, input, widget, widgetLocator)
},
{ signal }
)
@@ -303,7 +306,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const widget = resolved.inputNode.getWidgetFromSlot(resolved.input)
if (!widget) continue
this.#setWidget(subgraphInput, input, widget)
this.#setWidget(subgraphInput, input, widget, resolved.input.widget)
break
}
}
@@ -312,11 +315,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
#setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
widget: Readonly<IBaseWidget>
widget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined
) {
// Use the first matching widget
const targetWidget = toConcreteWidget(widget, this)
const promotedWidget = targetWidget.createCopyForNode(this)
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
this
)
Object.assign(promotedWidget, {
get name() {
@@ -374,11 +379,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
const backingInput =
targetWidget.node.findInputSlot(widget.name, true)?.widget ?? {}
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
Object.setPrototypeOf(input.widget, backingInput)
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
input._widget = promotedWidget
}

View File

@@ -1862,5 +1862,17 @@
"showGroups": "Show Frames/Groups",
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
},
"assetBrowser": {
"assets": "Assets",
"browseAssets": "Browse Assets",
"noAssetsFound": "No assets found",
"tryAdjustingFilters": "Try adjusting your search or filters",
"loadingModels": "Loading {type}...",
"connectionError": "Please check your connection and try again",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Search assets...",
"allModels": "All Models",
"unknown": "Unknown"
}
}

View File

@@ -2,6 +2,11 @@ import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { initializeApp } from 'firebase/app'
import {
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
} from 'firebase/auth'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -9,7 +14,7 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { VueFire, VueFireAuthWithDependencies } from 'vuefire'
import { FIREBASE_CONFIG } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
@@ -66,6 +71,18 @@ app
.use(i18n)
.use(VueFire, {
firebaseApp,
modules: [VueFireAuth()]
modules: [
// Configure Firebase Auth persistence: localStorage first, IndexedDB last.
// Localstorage is preferred to IndexedDB for mobile Safari compatibility.
VueFireAuthWithDependencies({
dependencies: {
persistence: [
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
]
}
})
]
})
.mount('#vue-app')

View File

@@ -0,0 +1,42 @@
<template>
<div class="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1">
<span
v-for="badge in badges"
:key="badge.label"
:class="
cn(
'px-2 py-1 rounded text-xs font-medium uppercase tracking-wider text-white',
getBadgeColor(badge.type)
)
"
>
{{ badge.label }}
</span>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
type AssetBadge = {
label: string
type: 'type' | 'base' | 'size'
}
defineProps<{
badges: AssetBadge[]
}>()
function getBadgeColor(type: AssetBadge['type']): string {
switch (type) {
case 'type':
return 'bg-blue-100/90 dark-theme:bg-blue-100/80'
case 'base':
return 'bg-success-100/90 dark-theme:bg-success-100/80'
case 'size':
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
default:
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
}
}
</script>

View File

@@ -0,0 +1,178 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import {
createMockAssets,
mockAssets
} from '@/platform/assets/fixtures/ui-mock-assets'
// Story arguments interface
interface StoryArgs {
nodeType: string
inputName: string
currentValue: string
showLeftPanel?: boolean
}
const meta: Meta<StoryArgs> = {
title: 'Platform/Assets/AssetBrowserModal',
component: AssetBrowserModal,
parameters: {
layout: 'fullscreen'
},
argTypes: {
nodeType: {
control: 'select',
options: ['CheckpointLoaderSimple', 'VAELoader', 'ControlNetLoader'],
description: 'ComfyUI node type for context'
},
inputName: {
control: 'select',
options: ['ckpt_name', 'vae_name', 'control_net_name'],
description: 'Widget input name'
},
currentValue: {
control: 'text',
description: 'Current selected asset value'
},
showLeftPanel: {
control: 'boolean',
description: 'Whether to show the left panel with categories'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
// Modal Layout Stories
export const Default: Story = {
args: {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
showLeftPanel: false
},
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
}
return {
...args,
onAssetSelect,
onClose,
assets: mockAssets
}
},
template: `
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
</div>
`
})
}
// Story demonstrating single asset type (auto-hides left panel)
export const SingleAssetType: Story = {
args: {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
showLeftPanel: false
},
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
}
// Create assets with only one type (checkpoints)
const singleTypeAssets = createMockAssets(15).map((asset) => ({
...asset,
type: 'checkpoint'
}))
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Modal with assets of only one type (checkpoint) - left panel auto-hidden.'
}
}
}
}
// Story with left panel explicitly hidden
export const NoLeftPanel: Story = {
args: {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
showLeftPanel: false
},
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
}
return { ...args, onAssetSelect, onClose, assets: mockAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Modal with left panel explicitly disabled via showLeftPanel=false.'
}
}
}
}

View File

@@ -0,0 +1,95 @@
<template>
<BaseModalLayout
data-component-id="AssetBrowserModal"
class="size-full max-h-full max-w-full min-w-0"
:content-title="contentTitle"
@close="handleClose"
>
<template v-if="shouldShowLeftPanel" #leftPanel>
<LeftSidePanel
v-model="selectedCategory"
data-component-id="AssetBrowserModal-LeftSidePanel"
:nav-items="availableCategories"
>
<template #header-icon>
<i-lucide:folder class="size-4" />
</template>
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
</LeftSidePanel>
</template>
<template #header>
<SearchBox
v-model="searchQuery"
size="lg"
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
class="max-w-96"
/>
</template>
<template #content>
<AssetGrid
:assets="filteredAssets"
@asset-select="handleAssetSelectAndEmit"
/>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SearchBox from '@/components/input/SearchBox.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const props = defineProps<{
nodeType?: string
inputName?: string
onSelect?: (assetPath: string) => void
onClose?: () => void
showLeftPanel?: boolean
assets?: AssetItem[]
}>()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
close: []
}>()
// Use AssetBrowser composable for all business logic
const {
searchQuery,
selectedCategory,
availableCategories,
contentTitle,
filteredAssets,
selectAsset
} = useAssetBrowser(props.assets)
// Dialog controls panel visibility via prop
const shouldShowLeftPanel = computed(() => {
return props.showLeftPanel ?? true
})
// Handle close button - call both the prop callback and emit the event
const handleClose = () => {
props.onClose?.()
emit('close')
}
// Handle asset selection and emit to parent
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
selectAsset(asset) // This logs the selection for dev mode
emit('asset-select', asset) // Emit the full asset object
// Call prop callback if provided
if (props.onSelect) {
props.onSelect(asset.name) // Use asset name as the asset path
}
}
</script>

View File

@@ -0,0 +1,182 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
// Use the first mock asset as base and transform it to display format
const baseAsset = mockAssets[0]
const createAssetData = (
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem => ({
...baseAsset,
description:
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
formattedSize: '2.1 GB',
badges: [
{ label: 'checkpoints', type: 'type' },
{ label: '2.1 GB', type: 'size' }
],
stats: {
formattedDate: '3/15/25',
downloadCount: '1.8k',
stars: '4.2k'
},
...overrides
})
const meta: Meta<typeof AssetCard> = {
title: 'Platform/Assets/AssetCard',
component: AssetCard,
parameters: {
layout: 'centered'
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Interactive: Story = {
args: {
asset: createAssetData(),
interactive: true
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
docs: {
description: {
story:
'Default AssetCard with complete data including badges and all stats.'
}
}
}
}
export const NonInteractive: Story = {
args: {
asset: createAssetData(),
interactive: false
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
docs: {
description: {
story:
'AssetCard in non-interactive mode - renders as div without button semantics.'
}
}
}
}
export const EdgeCases: Story = {
render: () => ({
components: { AssetCard },
setup() {
const edgeCases = [
// Default case for comparison
createAssetData({
name: 'Complete Data',
description: 'Asset with all data present for comparison'
}),
// No badges
createAssetData({
id: 'no-badges',
name: 'No Badges',
description: 'Testing graceful handling when badges are not provided',
badges: []
}),
// No stars
createAssetData({
id: 'no-stars',
name: 'No Stars',
description: 'Testing missing stars data gracefully',
stats: {
downloadCount: '1.8k',
formattedDate: '3/15/25'
}
}),
// No downloads
createAssetData({
id: 'no-downloads',
name: 'No Downloads',
description: 'Testing missing downloads data gracefully',
stats: {
stars: '4.2k',
formattedDate: '3/15/25'
}
}),
// No date
createAssetData({
id: 'no-date',
name: 'No Date',
description: 'Testing missing date data gracefully',
stats: {
stars: '4.2k',
downloadCount: '1.8k'
}
}),
// No stats at all
createAssetData({
id: 'no-stats',
name: 'No Stats',
description: 'Testing when all stats are missing',
stats: {}
}),
// Long description
createAssetData({
id: 'long-desc',
name: 'Long Description',
description:
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
}),
// Minimal data
createAssetData({
id: 'minimal',
name: 'Minimal',
description: 'Basic model',
tags: ['models'],
badges: [],
stats: {}
})
]
return { edgeCases }
},
template: `
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-gray-900">
<AssetCard
v-for="asset in edgeCases"
:key="asset.id"
:asset="asset"
:interactive="true"
@select="(asset) => console.log('Selected:', asset)"
/>
</div>
`
}),
parameters: {
layout: 'fullscreen',
docs: {
description: {
story:
'All AssetCard edge cases in a grid layout to test graceful handling of missing data, badges, stats, and long descriptions.'
}
}
}
}

View File

@@ -0,0 +1,111 @@
<template>
<component
:is="interactive ? 'button' : 'div'"
data-component-id="AssetCard"
:data-asset-id="asset.id"
v-bind="elementProps"
:class="
cn(
// Base layout and container styles (always applied)
'rounded-xl overflow-hidden transition-all duration-200',
// Button-specific styles
interactive && [
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
],
// Div-specific styles
!interactive && [
'bg-ivory-100 border border-gray-300',
'dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600'
]
)
"
@click="interactive && $emit('select', asset)"
@keydown.enter="interactive && $emit('select', asset)"
>
<div class="relative w-full aspect-square overflow-hidden">
<div
class="w-full h-full bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600 flex items-center justify-center"
></div>
<AssetBadgeGroup :badges="asset.badges" />
</div>
<div class="p-4 h-32 flex flex-col justify-between">
<div>
<h3
:class="
cn(
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
'text-slate-800',
'dark-theme:text-white'
)
"
>
{{ asset.name }}
</h3>
<p
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
'text-stone-300',
'dark-theme:text-stone-200'
)
"
:title="asset.description"
>
{{ asset.description }}
</p>
</div>
<div
:class="
cn(
'flex gap-4 text-xs',
'text-stone-400',
'dark-theme:text-stone-300'
)
"
>
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i-lucide:star class="size-3" />
{{ asset.stats.stars }}
</span>
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
<i-lucide:download class="size-3" />
{{ asset.stats.downloadCount }}
</span>
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
<i-lucide:clock class="size-3" />
{{ asset.stats.formattedDate }}
</span>
</div>
</div>
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
asset: AssetDisplayItem
interactive?: boolean
}>()
const elementProps = computed(() =>
props.interactive
? {
type: 'button',
'aria-label': `Select asset ${props.asset.name}`
}
: {}
)
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div :class="containerClasses" data-component-id="asset-filter-bar">
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
<MultiSelect
v-model="fileFormats"
label="File formats"
:options="fileFormatOptions"
:class="selectClasses"
data-component-id="asset-filter-file-formats"
@update:model-value="handleFilterChange"
/>
<MultiSelect
v-model="baseModels"
label="Base models"
:options="baseModelOptions"
:class="selectClasses"
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
/>
</div>
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
<SingleSelect
v-model="sortBy"
label="Sort by"
:options="sortOptions"
:class="selectClasses"
data-component-id="asset-filter-sort"
@update:model-value="handleFilterChange"
>
<template #icon>
<i-lucide:arrow-up-down class="size-3" />
</template>
</SingleSelect>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { SelectOption } from '@/components/input/types'
import { cn } from '@/utils/tailwindUtil'
export interface FilterState {
fileFormats: string[]
baseModels: string[]
sortBy: string
}
const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const sortBy = ref('name-asc')
// TODO: Make fileFormatOptions configurable via props or assetService
// Should support dynamic file formats based on available assets or server capabilities
const fileFormatOptions = [
{ name: '.ckpt', value: 'ckpt' },
{ name: '.safetensors', value: 'safetensors' },
{ name: '.pt', value: 'pt' }
]
// TODO: Make baseModelOptions configurable via props or assetService
// Should support dynamic base models based on available assets or server detection
const baseModelOptions = [
{ name: 'SD 1.5', value: 'sd15' },
{ name: 'SD XL', value: 'sdxl' },
{ name: 'SD 3.5', value: 'sd35' }
]
// TODO: Make sortOptions configurable via props
// Different asset types might need different sorting options
const sortOptions = [
{ name: 'A-Z', value: 'name-asc' },
{ name: 'Z-A', value: 'name-desc' },
{ name: 'Recent', value: 'recent' },
{ name: 'Popular', value: 'popular' }
]
const emit = defineEmits<{
filterChange: [filters: FilterState]
}>()
const containerClasses = cn(
'flex gap-4 items-center justify-between',
'px-6 pt-2 pb-6'
)
const leftSideClasses = cn('flex gap-4 items-center')
const rightSideClasses = cn('flex items-center')
const selectClasses = cn('min-w-32')
function handleFilterChange() {
emit('filterChange', {
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
baseModels: baseModels.value.map((option: SelectOption) => option.value),
sortBy: sortBy.value
})
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div
data-component-id="AssetGrid"
:style="gridStyle"
role="grid"
aria-label="Asset collection"
:aria-rowcount="-1"
:aria-colcount="-1"
:aria-setsize="assets.length"
>
<AssetCard
v-for="asset in assets"
:key="asset.id"
:asset="asset"
:interactive="true"
role="gridcell"
@select="$emit('assetSelect', $event)"
/>
<!-- Empty state -->
<div
v-if="assets.length === 0"
:class="
cn(
'col-span-full flex flex-col items-center justify-center py-16',
'text-stone-300 dark-theme:text-stone-200'
)
"
>
<i-lucide:search class="size-10 mb-4" />
<h3 class="text-lg font-medium mb-2">
{{ $t('assetBrowser.noAssetsFound') }}
</h3>
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
</div>
<!-- Loading state -->
<div
v-if="loading"
class="col-span-full flex items-center justify-center py-16"
>
<i-lucide:loader
:class="
cn('size-6 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { createGridStyle } from '@/utils/gridUtil'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
assets: AssetDisplayItem[]
loading?: boolean
}>()
defineEmits<{
assetSelect: [asset: AssetDisplayItem]
}>()
// Use same grid style as BaseModalLayout
const gridStyle = computed(() => createGridStyle())
</script>

View File

@@ -0,0 +1,188 @@
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModel,
getAssetDescription
} from '@/platform/assets/utils/assetMetadataUtils'
import { formatSize } from '@/utils/formatUtil'
type AssetBadge = {
label: string
type: 'type' | 'base' | 'size'
}
// Display properties for transformed assets
export interface AssetDisplayItem extends AssetItem {
description: string
formattedSize: string
badges: AssetBadge[]
stats: {
formattedDate?: string
downloadCount?: string
stars?: string
}
}
/**
* Asset Browser composable
* Manages search, filtering, asset transformation and selection logic
*/
export function useAssetBrowser(assets: AssetItem[] = []) {
// State
const searchQuery = ref('')
const selectedCategory = ref('all')
const sortBy = ref('name')
// Transform API asset to display asset
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
// Extract description from metadata or create from tags
const typeTag = asset.tags.find((tag) => tag !== 'models')
const description =
getAssetDescription(asset) ||
`${typeTag || t('assetBrowser.unknown')} model`
// Format file size
const formattedSize = formatSize(asset.size)
// Create badges from tags and metadata
const badges: AssetBadge[] = []
// Type badge from non-root tag
if (typeTag) {
badges.push({ label: typeTag, type: 'type' })
}
// Base model badge from metadata
const baseModel = getAssetBaseModel(asset)
if (baseModel) {
badges.push({
label: baseModel,
type: 'base'
})
}
// Size badge
badges.push({ label: formattedSize, type: 'size' })
// Create display stats from API data
const stats = {
formattedDate: new Date(asset.created_at).toLocaleDateString(),
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
return {
...asset,
description,
formattedSize,
badges,
stats
}
}
// Extract available categories from assets
const availableCategories = computed(() => {
const categorySet = new Set<string>()
assets.forEach((asset) => {
// Second tag is the category (after 'models' root tag)
if (asset.tags.length > 1 && asset.tags[0] === 'models') {
categorySet.add(asset.tags[1])
}
})
return [
{
id: 'all',
label: t('assetBrowser.allModels'),
icon: 'icon-[lucide--folder]'
},
...Array.from(categorySet)
.sort()
.map((category) => ({
id: category,
label: category.charAt(0).toUpperCase() + category.slice(1),
icon: 'icon-[lucide--package]'
}))
]
})
// Compute content title from selected category
const contentTitle = computed(() => {
if (selectedCategory.value === 'all') {
return t('assetBrowser.allModels')
}
const category = availableCategories.value.find(
(cat) => cat.id === selectedCategory.value
)
return category?.label || t('assetBrowser.assets')
})
// Filter functions
const filterByCategory = (category: string) => (asset: AssetItem) => {
if (category === 'all') return true
return asset.tags.includes(category)
}
const filterByQuery = (query: string) => (asset: AssetItem) => {
if (!query) return true
const lowerQuery = query.toLowerCase()
const description = getAssetDescription(asset)
return (
asset.name.toLowerCase().includes(lowerQuery) ||
(description && description.toLowerCase().includes(lowerQuery)) ||
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
)
}
// Computed filtered and transformed assets
const filteredAssets = computed(() => {
const filtered = assets
.filter(filterByCategory(selectedCategory.value))
.filter(filterByQuery(searchQuery.value))
// Sort assets
filtered.sort((a, b) => {
switch (sortBy.value) {
case 'date':
return (
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
case 'name':
default:
return a.name.localeCompare(b.name)
}
})
// Transform to display format
return filtered.map(transformAssetForDisplay)
})
// Actions
function selectAsset(asset: AssetDisplayItem): UUID {
if (import.meta.env.DEV) {
console.log('Asset selected:', asset.id, asset.name)
}
return asset.id
}
return {
// State
searchQuery,
selectedCategory,
sortBy,
// Computed
availableCategories,
contentTitle,
filteredAssets,
// Actions
selectAsset,
transformAssetForDisplay
}
}

View File

@@ -0,0 +1,203 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
// Component that simulates the useAssetBrowserDialog functionality with working close
const DialogDemoComponent = {
components: { AssetBrowserModal },
setup() {
const isDialogOpen = ref(false)
const currentNodeType = ref('CheckpointLoaderSimple')
const currentInputName = ref('ckpt_name')
const currentValue = ref('')
const handleOpenDialog = (
nodeType: string,
inputName: string,
value = ''
) => {
currentNodeType.value = nodeType
currentInputName.value = inputName
currentValue.value = value
isDialogOpen.value = true
}
const handleCloseDialog = () => {
isDialogOpen.value = false
}
const handleAssetSelected = (assetPath: string) => {
console.log('Asset selected:', assetPath)
alert(`Selected asset: ${assetPath}`)
isDialogOpen.value = false // Auto-close like the real composable
}
const handleOpenWithCurrentValue = () => {
handleOpenDialog(
'CheckpointLoaderSimple',
'ckpt_name',
'realistic_vision_v5.safetensors'
)
}
return {
isDialogOpen,
currentNodeType,
currentInputName,
currentValue,
handleOpenDialog,
handleOpenWithCurrentValue,
handleCloseDialog,
handleAssetSelected,
mockAssets
}
},
template: `
<div class="relative">
<div class="p-8 space-y-4">
<h2 class="text-2xl font-bold mb-6">Asset Browser Dialog Demo</h2>
<div class="space-y-4">
<div>
<h3 class="text-lg font-semibold mb-2">Different Node Types</h3>
<div class="flex gap-3 flex-wrap">
<button
@click="handleOpenDialog('CheckpointLoaderSimple', 'ckpt_name')"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Browse Checkpoints
</button>
<button
@click="handleOpenDialog('VAELoader', 'vae_name')"
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Browse VAE
</button>
<button
@click="handleOpenDialog('ControlNetLoader', 'control_net_name')"
class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
>
Browse ControlNet
</button>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-2">With Current Value</h3>
<button
@click="handleOpenWithCurrentValue"
class="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700"
>
Change Current Model
</button>
<p class="text-sm text-gray-600 mt-1">
Opens with "realistic_vision_v5.safetensors" as current value
</p>
</div>
<div class="mt-8 p-4 bg-gray-100 rounded">
<h4 class="font-semibold mb-2">Instructions:</h4>
<ul class="text-sm space-y-1">
<li>• Click any button to open the Asset Browser dialog</li>
<li>• Select an asset to see the callback in action</li>
<li>• Check the browser console for logged events</li>
<li>• Try toggling the left panel with different asset types</li>
<li>• Close button will work properly in this demo</li>
</ul>
</div>
</div>
</div>
<!-- Dialog Modal Overlay -->
<div
v-if="isDialogOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="handleCloseDialog"
>
<div class="w-[80vw] h-[80vh] max-w-[80vw] max-h-[80vh] rounded-2xl overflow-hidden">
<AssetBrowserModal
:assets="mockAssets"
:node-type="currentNodeType"
:input-name="currentInputName"
:current-value="currentValue"
@asset-select="handleAssetSelected"
@close="handleCloseDialog"
/>
</div>
</div>
</div>
`
}
const meta: Meta = {
title: 'Platform/Assets/useAssetBrowserDialog',
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'Demonstrates the AssetBrowserModal functionality as used by the useAssetBrowserDialog composable.'
}
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Demo: Story = {
render: () => ({
components: { DialogDemoComponent },
template: `
<div>
<DialogDemoComponent />
<!-- Code Example Section -->
<div class="p-8 border-t border-gray-200 bg-gray-50">
<h2 class="text-2xl font-bold mb-4">Code Example</h2>
<p class="text-gray-600 mb-4">
This is how you would use the composable in your component:
</p>
<div class="bg-white p-4 rounded-lg border shadow-sm">
<pre><code class="text-sm">import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
export default {
setup() {
const assetBrowserDialog = useAssetBrowserDialog()
const openBrowser = () => {
assetBrowserDialog.show({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
onAssetSelected: (assetPath) => {
console.log('Selected:', assetPath)
// Update your component state
}
})
}
return { openBrowser }
}
}</code></pre>
</div>
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
<p class="text-sm text-blue-800">
<strong>💡 Try it:</strong> Use the interactive buttons above to see this code in action!
</p>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Complete demo showing both interactive functionality and code examples for using useAssetBrowserDialog to open the Asset Browser modal programmatically.'
}
}
}
}

View File

@@ -0,0 +1,66 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import { useDialogStore } from '@/stores/dialogStore'
interface AssetBrowserDialogProps {
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
nodeType: string
/** Widget input name (e.g., 'ckpt_name') */
inputName: string
/** Current selected asset value */
currentValue?: string
/** Callback for when an asset is selected */
onAssetSelected?: (assetPath: string) => void
}
export const useAssetBrowserDialog = () => {
const dialogStore = useDialogStore()
const dialogKey = 'global-asset-browser'
function hide() {
dialogStore.closeDialog({ key: dialogKey })
}
function show(props: AssetBrowserDialogProps) {
const handleAssetSelected = (assetPath: string) => {
props.onAssetSelected?.(assetPath)
hide() // Auto-close on selection
}
const handleClose = () => {
hide()
}
// Default dialog configuration for AssetBrowserModal
const dialogComponentProps = {
headless: true,
modal: true,
closable: false,
pt: {
root: {
class: 'rounded-2xl overflow-hidden'
},
header: {
class: 'p-0 hidden'
},
content: {
class: 'p-0 m-0 h-full w-full'
}
}
}
dialogStore.showDialog({
key: dialogKey,
component: AssetBrowserModal,
props: {
nodeType: props.nodeType,
inputName: props.inputName,
currentValue: props.currentValue,
onSelect: handleAssetSelected,
onClose: handleClose
},
dialogComponentProps
})
}
return { show, hide }
}

View File

@@ -0,0 +1,56 @@
import { uniqWith } from 'es-toolkit'
import { computed } from 'vue'
import type { SelectOption } from '@/components/input/types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Composable that extracts available filter options from asset data
* Provides reactive computed properties for file formats and base models
*/
export function useAssetFilterOptions(assets: AssetItem[] = []) {
/**
* Extract unique file formats from asset names
* Returns sorted SelectOption array with extensions
*/
const availableFileFormats = computed<SelectOption[]>(() => {
const extensions = assets
.map((asset) => {
const extension = asset.name.split('.').pop()
return extension && extension !== asset.name ? extension : null
})
.filter((extension): extension is string => extension !== null)
const uniqueExtensions = uniqWith(extensions, (a, b) => a === b)
return uniqueExtensions.sort().map((format) => ({
name: `.${format}`,
value: format
}))
})
/**
* Extract unique base models from asset user metadata
* Returns sorted SelectOption array with base model names
*/
const availableBaseModels = computed<SelectOption[]>(() => {
const models = assets
.map((asset) => asset.user_metadata?.base_model)
.filter(
(baseModel): baseModel is string =>
baseModel !== undefined && typeof baseModel === 'string'
)
const uniqueModels = uniqWith(models, (a, b) => a === b)
return uniqueModels.sort().map((model) => ({
name: model,
value: model
}))
})
return {
availableFileFormats,
availableBaseModels
}
}

View File

@@ -0,0 +1,128 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// 🎭 OBVIOUSLY FAKE MOCK DATA - DO NOT USE IN PRODUCTION! 🎭
const fakeFunnyModelNames = [
'🎯_totally_real_model_v420.69',
'🚀_definitely_not_fake_v999',
'🎪_super_legit_checkpoint_pro_max',
'🦄_unicorn_dreams_totally_real.model',
'🍕_pizza_generator_supreme',
'🎸_rock_star_fake_data_v1337',
'🌮_taco_tuesday_model_deluxe',
'🦖_dino_nugget_generator_v3',
'🎮_gamer_fuel_checkpoint_xl',
'🍄_mushroom_kingdom_diffusion',
'🏴_pirate_treasure_model_arr',
'🦋_butterfly_effect_generator',
'🎺_jazz_hands_checkpoint_pro',
'🥨_pretzel_logic_model_v2',
'🌙_midnight_snack_generator',
'🎭_drama_llama_checkpoint',
'🧙_wizard_hat_diffusion_xl',
'🎪_circus_peanut_model_v4',
'🦒_giraffe_neck_generator',
'🎲_random_stuff_checkpoint_max'
]
const obviouslyFakeDescriptions = [
'⚠️ FAKE DATA: Generates 100% authentic fake images with premium mock quality',
'🎭 MOCK ALERT: This totally real model creates absolutely genuine fake content',
'🚨 NOT REAL: Professional-grade fake imagery for your mock data needs',
'🎪 DEMO ONLY: Circus-quality fake generation with extra mock seasoning',
'🍕 FAKE FOOD: Generates delicious fake pizzas (not edible in reality)',
"🎸 MOCK ROCK: Creates fake rock stars who definitely don't exist",
'🌮 TACO FAKERY: Tuesday-themed fake tacos for your mock appetite',
'🦖 PREHISTORIC FAKE: Generates extinct fake dinosaurs for demo purposes',
'🎮 FAKE GAMING: Level up your mock data with obviously fake content',
'🍄 FUNGI FICTION: Magical fake mushrooms from the demo dimension',
'🏴‍☠️ FAKE TREASURE: Arr! This be mock data for ye demo needs, matey!',
'🦋 DEMO EFFECT: Small fake changes create big mock differences',
'🎺 JAZZ FAKERY: Smooth fake jazz for your mock listening pleasure',
'🥨 MOCK LOGIC: Twisted fake reasoning for your demo requirements',
'🌙 MIDNIGHT MOCK: Late-night fake snacks for your demo hunger',
'🎭 FAKE DRAMA: Over-the-top mock emotions for demo entertainment',
'🧙‍♀️ WIZARD MOCK: Magically fake spells cast with demo ingredients',
'🎪 CIRCUS FAKE: Big top mock entertainment under the demo tent',
'🦒 TALL FAKE: Reaches new heights of obviously fake content',
'🎲 RANDOM MOCK: Generates random fake stuff for your demo pleasure'
]
// API-compliant tag structure: first tag must be root (models/input/output), second is category
const modelCategories = ['checkpoints', 'loras', 'embeddings', 'vae']
const baseModels = ['sd15', 'sdxl', 'sd35']
const fileExtensions = ['.safetensors', '.ckpt', '.pt']
const mimeTypes = [
'application/octet-stream',
'application/x-pytorch',
'application/x-safetensors'
]
function getRandomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)]
}
function getRandomNumber(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function getRandomISODate(): string {
const start = new Date('2024-01-01').getTime()
const end = new Date('2024-12-31').getTime()
const randomTime = start + Math.random() * (end - start)
return new Date(randomTime).toISOString()
}
function generateFakeAssetHash(): string {
const chars = '0123456789abcdef'
let hash = 'blake3:'
for (let i = 0; i < 64; i++) {
hash += chars[Math.floor(Math.random() * chars.length)]
}
return hash
}
// 🎭 CREATES OBVIOUSLY FAKE ASSETS FOR DEMO/TEST PURPOSES ONLY! 🎭
export function createMockAssets(count: number = 20): AssetItem[] {
return Array.from({ length: count }, (_, index) => {
const category = getRandomElement(modelCategories)
const baseModel = getRandomElement(baseModels)
const extension = getRandomElement(fileExtensions)
const mimeType = getRandomElement(mimeTypes)
const sizeInBytes = getRandomNumber(
500 * 1024 * 1024,
8 * 1024 * 1024 * 1024
) // 500MB to 8GB
const createdAt = getRandomISODate()
const updatedAt = createdAt
const lastAccessTime = getRandomISODate()
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
return {
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
name: fakeFileName,
asset_hash: generateFakeAssetHash(),
size: sizeInBytes,
mime_type: mimeType,
tags: [
'models', // Root tag (required first)
category, // Category tag (required second for models)
'fake-data', // Obviously fake tag
...(Math.random() > 0.5 ? ['demo-mode'] : ['test-only']),
...(Math.random() > 0.7 ? ['obviously-mock'] : [])
],
preview_url: `/api/assets/mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake/content`,
created_at: createdAt,
updated_at: updatedAt,
last_access_time: lastAccessTime,
user_metadata: {
description: obviouslyFakeDescriptions[index],
base_model: baseModel,
original_name: fakeFunnyModelNames[index],
warning: '🚨 THIS IS FAKE DEMO DATA - NOT A REAL MODEL! 🚨'
}
}
})
}
export const mockAssets = createMockAssets(20)

View File

@@ -1,12 +1,19 @@
import { z } from 'zod'
// Zod schemas for asset API validation
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
const zAsset = z.object({
id: z.string(),
name: z.string(),
tags: z.array(z.string()),
asset_hash: z.string(),
size: z.number(),
created_at: z.string().optional()
mime_type: z.string(),
tags: z.array(z.string()),
preview_url: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
last_access_time: z.string(),
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
preview_id: z.string().nullable().optional()
})
const zAssetResponse = z.object({
@@ -20,19 +27,22 @@ const zModelFolder = z.object({
folders: z.array(z.string())
})
// Zod schema for ModelFile to align with interface
const zModelFile = z.object({
name: z.string(),
pathIndex: z.number()
})
// Export schemas following repository patterns
export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>
// Common interfaces for API responses
export interface ModelFile {
name: string
pathIndex: number
}
// Legacy interface for backward compatibility (now aligned with Zod schema)
export interface ModelFolderInfo {
name: string
folders: string[]

View File

@@ -67,7 +67,7 @@ function createAssetService() {
)
// Blacklist directories we don't want to show
const blacklistedDirectories = ['configs']
const blacklistedDirectories = new Set(['configs'])
// Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>(
@@ -75,7 +75,7 @@ function createAssetService() {
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
?.flatMap((asset) => asset.tags)
?.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
) ?? []
)

View File

@@ -0,0 +1,27 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Type-safe utilities for extracting metadata from assets
*/
/**
* Safely extracts string description from asset metadata
* @param asset - The asset to extract description from
* @returns The description string or null if not present/not a string
*/
export function getAssetDescription(asset: AssetItem): string | null {
return typeof asset.user_metadata?.description === 'string'
? asset.user_metadata.description
: null
}
/**
* Safely extracts string base_model from asset metadata
* @param asset - The asset to extract base_model from
* @returns The base_model string or null if not present/not a string
*/
export function getAssetBaseModel(asset: AssetItem): string | null {
return typeof asset.user_metadata?.base_model === 'string'
? asset.user_metadata.base_model
: null
}

View File

@@ -11,12 +11,21 @@ import { app } from '@/scripts/app'
*/
export function useCanvasInteractions() {
const settingStore = useSettingStore()
const { getCanvas } = useCanvasStore()
const canvasStore = useCanvasStore()
const { getCanvas } = canvasStore
const isStandardNavMode = computed(
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
)
/**
* Whether Vue node components should handle pointer events.
* Returns false when canvas is in read-only/panning mode (e.g., space key held for panning).
*/
const shouldHandleNodePointerEvents = computed(
() => !(canvasStore.canvas?.read_only ?? false)
)
/**
* Handles wheel events from UI components that should be forwarded to canvas
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
@@ -97,6 +106,7 @@ export function useCanvasInteractions() {
return {
handleWheel,
handlePointer,
forwardEventToCanvas
forwardEventToCanvas,
shouldHandleNodePointerEvents
}
}

View File

@@ -5,7 +5,7 @@
* CRDT ensures conflict-free operations for both single and multi-user scenarios.
*/
import log from 'loglevel'
import { type ComputedRef, type Ref, computed, customRef } from 'vue'
import { type ComputedRef, type Ref, computed, customRef, ref } from 'vue'
import * as Y from 'yjs'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
@@ -134,6 +134,9 @@ class LayoutStoreImpl implements LayoutStore {
private slotSpatialIndex: SpatialIndexManager // For slots
private rerouteSpatialIndex: SpatialIndexManager // For reroutes
// Vue dragging state for selection toolbox (public ref for direct mutation)
public isDraggingVueNodes = ref(false)
constructor() {
// Initialize Yjs data structures
this.ynodes = this.ydoc.getMap('nodes')

View File

@@ -10,7 +10,9 @@
'bg-white dark-theme:bg-charcoal-800',
'lg-node absolute rounded-2xl',
'border border-solid border-sand-100 dark-theme:border-charcoal-600',
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
'outline-transparent -outline-offset-2 outline-2',
borderClass,
outlineClass,
@@ -21,7 +23,9 @@
'will-change-transform': isDragging
},
lodCssClass,
'pointer-events-auto'
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
)
"
:style="[
@@ -34,6 +38,7 @@
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@wheel="handleWheel"
>
<div class="flex items-center">
<template v-if="isCollapsed">
@@ -147,7 +152,9 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
@@ -236,6 +243,14 @@ const hasAnyError = computed(
const bypassed = computed((): boolean => nodeData.mode === 4)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const {
handleWheel,
handlePointer,
forwardEventToCanvas,
shouldHandleNodePointerEvents
} = useCanvasInteractions()
// LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => zoomLevel)
const {
@@ -361,14 +376,27 @@ const handlePointerDown = (event: PointerEvent) => {
return
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Start drag using layout system
isDragging.value = true
// Set Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = true
startDrag(event)
lastY.value = event.clientY
lastX.value = event.clientX
}
const handlePointerMove = (event: PointerEvent) => {
// Check if this should be forwarded to canvas (e.g., space panning, middle mouse)
handlePointer(event)
if (isDragging.value) {
void handleLayoutDrag(event)
}
@@ -378,7 +406,17 @@ const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
// Clear Vue node dragging state for selection toolbox
layoutStore.isDraggingVueNodes.value = false
}
// Don't emit node-click when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
return
}
// Emit node-click for selection handling in GraphCanvas
const dx = event.clientX - lastX.value
const dy = event.clientY - lastY.value
@@ -401,6 +439,12 @@ const handleSlotClick = (
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
return
}
// Don't handle slot clicks when canvas is in panning mode
if (!shouldHandleNodePointerEvents.value) {
return
}
emit('slot-click', event, nodeData, slotIndex, isInput)
}

View File

@@ -2,7 +2,20 @@
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
{{ $t('Node Widgets Error') }}
</div>
<div v-else class="lg-node-widgets flex flex-col gap-2 pr-4">
<div
v-else
:class="
cn(
'lg-node-widgets flex flex-col gap-2 pr-4',
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
)
"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
>
<div
v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`"
@@ -46,6 +59,7 @@ import type {
} from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
// Import widget components directly
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
@@ -55,6 +69,7 @@ import {
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import InputSlot from './InputSlot.vue'
@@ -67,6 +82,14 @@ interface NodeWidgetsProps {
const props = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const handleWidgetPointerEvent = (event: PointerEvent) => {
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
}
}
// Error boundary implementation
const renderError = ref<string | null>(null)

View File

@@ -12,6 +12,7 @@ import type { Ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
interface NodeManager {
@@ -21,6 +22,7 @@ interface NodeManager {
export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const { shouldHandleNodePointerEvents } = useCanvasInteractions()
/**
* Handle node selection events
@@ -31,6 +33,8 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
nodeData: VueNodeData,
wasDragging: boolean
) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
@@ -69,6 +73,8 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
* Uses LiteGraph's native collapse method for proper state management
*/
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
if (!shouldHandleNodePointerEvents.value) return
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
@@ -86,6 +92,8 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
* Updates the title in LiteGraph for persistence across sessions
*/
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
if (!shouldHandleNodePointerEvents.value) return
if (!nodeManager.value) return
const node = nodeManager.value.getNode(nodeId)
@@ -103,6 +111,8 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
event: PointerEvent,
nodeData: VueNodeData
) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
@@ -123,6 +133,8 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
* Integrates with LiteGraph's context menu system
*/
const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
@@ -145,6 +157,8 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
* Prepares node for dragging and sets appropriate visual state
*/
const handleNodeDragStart = (event: DragEvent, nodeData: VueNodeData) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
@@ -173,6 +187,8 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
* Useful for selection toolbox or area selection
*/
const selectNodes = (nodeIds: string[], addToSelection = false) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
if (!addToSelection) {
@@ -193,6 +209,8 @@ export function useNodeEventHandlers(nodeManager: Ref<NodeManager | null>) {
* Deselect specific nodes
*/
const deselectNodes = (nodeIds: string[]) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
nodeIds.forEach((nodeId) => {

View File

@@ -0,0 +1,337 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ImageCompare from 'primevue/imagecompare'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetImageCompare, {
type ImageCompareValue
} from './WidgetImageCompare.vue'
describe('WidgetImageCompare Display', () => {
const createMockWidget = (
value: ImageCompareValue | string,
options: SimplifiedWidget['options'] = {}
): SimplifiedWidget<ImageCompareValue | string> => ({
name: 'test_imagecompare',
type: 'object',
value,
options
})
const mountComponent = (
widget: SimplifiedWidget<ImageCompareValue | string>,
readonly = false
) => {
return mount(WidgetImageCompare, {
global: {
plugins: [PrimeVue],
components: { ImageCompare }
},
props: {
widget,
readonly
}
})
}
describe('Component Rendering', () => {
it('renders imagecompare component with proper structure and styling', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
// Component exists
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
// Renders both images with correct URLs
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
// Images have proper styling classes
images.forEach((img) => {
expect(img.classes()).toContain('object-cover')
expect(img.classes()).toContain('w-full')
expect(img.classes()).toContain('h-full')
})
})
})
describe('Object Value Input', () => {
it('handles alt text correctly - custom, default, and empty', () => {
// Test custom alt text
const customAltValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg',
beforeAlt: 'Original design',
afterAlt: 'Updated design'
}
const customWrapper = mountComponent(createMockWidget(customAltValue))
const customImages = customWrapper.findAll('img')
expect(customImages[0].attributes('alt')).toBe('Original design')
expect(customImages[1].attributes('alt')).toBe('Updated design')
// Test default alt text
const defaultAltValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const defaultWrapper = mountComponent(createMockWidget(defaultAltValue))
const defaultImages = defaultWrapper.findAll('img')
expect(defaultImages[0].attributes('alt')).toBe('Before image')
expect(defaultImages[1].attributes('alt')).toBe('After image')
// Test empty string alt text (falls back to default)
const emptyAltValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg',
beforeAlt: '',
afterAlt: ''
}
const emptyWrapper = mountComponent(createMockWidget(emptyAltValue))
const emptyImages = emptyWrapper.findAll('img')
expect(emptyImages[0].attributes('alt')).toBe('Before image')
expect(emptyImages[1].attributes('alt')).toBe('After image')
})
it('handles missing and partial image URLs gracefully', () => {
// Missing URLs
const missingValue: ImageCompareValue = { before: '', after: '' }
const missingWrapper = mountComponent(createMockWidget(missingValue))
const missingImages = missingWrapper.findAll('img')
expect(missingImages[0].attributes('src')).toBe('')
expect(missingImages[1].attributes('src')).toBe('')
// Partial URLs
const partialValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: ''
}
const partialWrapper = mountComponent(createMockWidget(partialValue))
const partialImages = partialWrapper.findAll('img')
expect(partialImages[0].attributes('src')).toBe(
'https://example.com/before.jpg'
)
expect(partialImages[1].attributes('src')).toBe('')
})
})
describe('String Value Input', () => {
it('handles string value as before image only', () => {
const value = 'https://example.com/single.jpg'
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('https://example.com/single.jpg')
expect(images[1].attributes('src')).toBe('')
})
it('uses default alt text for string values', () => {
const value = 'https://example.com/single.jpg'
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
})
})
describe('Widget Options Handling', () => {
it('passes through accessibility options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
tabindex: 1,
ariaLabel: 'Compare images',
ariaLabelledby: 'compare-label'
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(1)
expect(imageCompare.props('ariaLabel')).toBe('Compare images')
expect(imageCompare.props('ariaLabelledby')).toBe('compare-label')
})
it('uses default tabindex when not provided', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('tabindex')).toBe(0)
})
it('passes through PrimeVue specific options', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value, {
unstyled: true,
pt: { root: { class: 'custom-class' } },
ptOptions: { mergeSections: true }
})
const wrapper = mountComponent(widget)
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.props('unstyled')).toBe(true)
expect(imageCompare.props('pt')).toEqual({
root: { class: 'custom-class' }
})
expect(imageCompare.props('ptOptions')).toEqual({ mergeSections: true })
})
})
describe('Readonly Mode', () => {
it('renders normally in readonly mode (no interaction restrictions)', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget, true)
// ImageCompare is display-only, readonly doesn't affect rendering
const imageCompare = wrapper.findComponent({ name: 'ImageCompare' })
expect(imageCompare.exists()).toBe(true)
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
})
})
describe('Edge Cases', () => {
it('handles null or undefined widget value', () => {
const widget = createMockWidget('')
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
expect(images[0].attributes('alt')).toBe('Before image')
expect(images[1].attributes('alt')).toBe('After image')
})
it('handles empty object value', () => {
const value: ImageCompareValue = {} as ImageCompareValue
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
})
it('handles malformed object value', () => {
const value = { randomProp: 'test', before: '', after: '' }
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
expect(images[0].attributes('src')).toBe('')
expect(images[1].attributes('src')).toBe('')
})
it('handles special content - long URLs, special characters, and long alt text', () => {
// Test very long URLs
const longUrl = 'https://example.com/' + 'a'.repeat(1000) + '.jpg'
const longUrlValue: ImageCompareValue = {
before: longUrl,
after: longUrl
}
const longUrlWrapper = mountComponent(createMockWidget(longUrlValue))
const longUrlImages = longUrlWrapper.findAll('img')
expect(longUrlImages[0].attributes('src')).toBe(longUrl)
expect(longUrlImages[1].attributes('src')).toBe(longUrl)
// Test special characters in URLs
const specialUrl =
'https://example.com/path with spaces & symbols!@#$.jpg'
const specialUrlValue: ImageCompareValue = {
before: specialUrl,
after: specialUrl
}
const specialUrlWrapper = mountComponent(
createMockWidget(specialUrlValue)
)
const specialUrlImages = specialUrlWrapper.findAll('img')
expect(specialUrlImages[0].attributes('src')).toBe(specialUrl)
expect(specialUrlImages[1].attributes('src')).toBe(specialUrl)
// Test very long alt text
const longAlt =
'Very long alt text that exceeds normal length: ' +
'description '.repeat(50)
const longAltValue: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg',
beforeAlt: longAlt,
afterAlt: longAlt
}
const longAltWrapper = mountComponent(createMockWidget(longAltValue))
const longAltImages = longAltWrapper.findAll('img')
expect(longAltImages[0].attributes('alt')).toBe(longAlt)
expect(longAltImages[1].attributes('alt')).toBe(longAlt)
})
})
describe('Template Structure', () => {
it('correctly assigns images to left and right template slots', () => {
const value: ImageCompareValue = {
before: 'https://example.com/before.jpg',
after: 'https://example.com/after.jpg'
}
const widget = createMockWidget(value)
const wrapper = mountComponent(widget)
const images = wrapper.findAll('img')
// First image (before) should be in left template slot
expect(images[0].attributes('src')).toBe('https://example.com/before.jpg')
// Second image (after) should be in right template slot
expect(images[1].attributes('src')).toBe('https://example.com/after.jpg')
})
})
describe('Integration', () => {
it('works with various URL types - data URLs and blob URLs', () => {
// Test data URLs
const dataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
const dataUrlValue: ImageCompareValue = {
before: dataUrl,
after: dataUrl
}
const dataUrlWrapper = mountComponent(createMockWidget(dataUrlValue))
const dataUrlImages = dataUrlWrapper.findAll('img')
expect(dataUrlImages[0].attributes('src')).toBe(dataUrl)
expect(dataUrlImages[1].attributes('src')).toBe(dataUrl)
// Test blob URLs
const blobUrl =
'blob:http://example.com/12345678-1234-1234-1234-123456789012'
const blobUrlValue: ImageCompareValue = {
before: blobUrl,
after: blobUrl
}
const blobUrlWrapper = mountComponent(createMockWidget(blobUrlValue))
const blobUrlImages = blobUrlWrapper.findAll('img')
expect(blobUrlImages[0].attributes('src')).toBe(blobUrl)
expect(blobUrlImages[1].attributes('src')).toBe(blobUrl)
})
})
})

View File

@@ -30,7 +30,7 @@ import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface ImageCompareValue {
export interface ImageCompareValue {
before: string
after: string
beforeAlt?: string

View File

@@ -115,6 +115,12 @@ const router = createRouter({
name: 'DesktopUpdateView',
component: () => import('@/views/DesktopUpdateView.vue'),
beforeEnter: guardElectronAccess
},
{
path: 'desktop-dialog/:dialogId',
name: 'DesktopDialogView',
component: () => import('@/views/DesktopDialogView.vue'),
beforeEnter: guardElectronAccess
}
]
}

View File

@@ -113,7 +113,7 @@ class MediaCacheService {
try {
// Fetch the media
const response = await fetch(src)
const response = await fetch(src, { cache: 'force-cache' })
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}

View File

@@ -6,12 +6,10 @@ import {
GoogleAuthProvider,
type User,
type UserCredential,
browserLocalPersistence,
createUserWithEmailAndPassword,
deleteUser,
onAuthStateChanged,
sendPasswordResetEmail,
setPersistence,
signInWithEmailAndPassword,
signInWithPopup,
signOut,
@@ -62,10 +60,12 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// Providers
const googleProvider = new GoogleAuthProvider()
googleProvider.addScope('email')
googleProvider.setCustomParameters({
prompt: 'select_account'
})
const githubProvider = new GithubAuthProvider()
githubProvider.addScope('user:email')
githubProvider.setCustomParameters({
prompt: 'select_account'
})
@@ -80,8 +80,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// Retrieves the Firebase Auth instance. Returns `null` on the server.
// When using this function on the client in TypeScript, you can force the type with `useFirebaseAuth()!`.
const auth = useFirebaseAuth()!
// Set persistence to localStorage (works in both browser and Electron)
void setPersistence(auth, browserLocalPersistence)
onAuthStateChanged(auth, (user) => {
currentUser.value = user

View File

@@ -0,0 +1,70 @@
<template>
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
<h1 class="font-inter font-semibold text-xl m-0 italic">
{{ t(`desktopDialogs.${id}.title`, title) }}
</h1>
<p class="whitespace-pre-wrap">
{{ t(`desktopDialogs.${id}.message`, message) }}
</p>
<div class="flex w-full gap-2">
<Button
v-for="button in buttons"
:key="button.label"
class="rounded-lg first:mr-auto"
:label="
t(
`desktopDialogs.${id}.buttons.${normalizeI18nKey(button.label)}`,
button.label
)
"
:severity="button.severity ?? 'secondary'"
@click="handleButtonClick(button)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useRoute } from 'vue-router'
import { type DialogAction, getDialog } from '@/constants/desktopDialogs'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
const route = useRoute()
const { id, title, message, buttons } = getDialog(route.params.dialogId)
const handleButtonClick = async (button: DialogAction) => {
await electronAPI().Dialog.clickButton(button.returnValue)
}
</script>
<style scoped>
@reference '../assets/css/style.css';
.p-button-secondary {
@apply text-white border-none bg-neutral-600;
}
.p-button-secondary:hover {
@apply bg-neutral-550;
}
.p-button-secondary:active {
@apply bg-neutral-500;
}
.p-button-danger {
@apply bg-coral-red-600;
}
.p-button-danger:hover {
@apply bg-coral-red-500;
}
.p-button-danger:active {
@apply bg-coral-red-400;
}
</style>

View File

@@ -6,6 +6,16 @@ import { iconCollection } from './build/customIconCollection'
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
safelist: [
'icon-[lucide--folder]',
'icon-[lucide--package]',
'icon-[lucide--image]',
'icon-[lucide--video]',
'icon-[lucide--box]',
'icon-[lucide--audio-waveform]',
'icon-[lucide--message-circle]'
],
plugins: [
addDynamicIconSelectors({
iconSets: {

View File

@@ -0,0 +1,304 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// Mock external dependencies with minimal functionality needed for business logic tests
vi.mock('@/components/input/SearchBox.vue', () => ({
default: {
name: 'SearchBox',
props: ['modelValue', 'size', 'placeholder', 'class'],
emits: ['update:modelValue'],
template: `
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
data-testid="search-box"
/>
`
}
}))
vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
default: {
name: 'BaseModalLayout',
props: ['contentTitle'],
emits: ['close'],
template: `
<div data-testid="base-modal-layout">
<div v-if="$slots.leftPanel" data-testid="left-panel">
<slot name="leftPanel" />
</div>
<div data-testid="header">
<slot name="header" />
</div>
<div data-testid="content">
<slot name="content" />
</div>
</div>
`
}
}))
vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
default: {
name: 'LeftSidePanel',
props: ['modelValue', 'navItems'],
emits: ['update:modelValue'],
template: `
<div data-testid="left-side-panel">
<button
v-for="item in navItems"
:key="item.id"
@click="$emit('update:modelValue', item.id)"
:data-testid="'nav-item-' + item.id"
:class="{ active: modelValue === item.id }"
>
{{ item.label }}
</button>
</div>
`
}
}))
vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
default: {
name: 'AssetGrid',
props: ['assets'],
emits: ['asset-select'],
template: `
<div data-testid="asset-grid">
<div
v-for="asset in assets"
:key="asset.id"
@click="$emit('asset-select', asset)"
:data-testid="'asset-' + asset.id"
class="asset-card"
>
{{ asset.name }}
</div>
<div v-if="assets.length === 0" data-testid="empty-state">
No assets found
</div>
</div>
`
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
describe('AssetBrowserModal', () => {
const createTestAsset = (
id: string,
name: string,
category: string
): AssetItem => ({
id,
name,
asset_hash: `blake3:${id.padEnd(64, '0')}`,
size: 1024000,
mime_type: 'application/octet-stream',
tags: ['models', category, 'test'],
preview_url: `/api/assets/${id}/content`,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
user_metadata: {
description: `Test ${name}`,
base_model: 'sd15'
}
})
const createWrapper = (
assets: AssetItem[] = [],
props: Record<string, unknown> = {}
) => {
const pinia = createPinia()
setActivePinia(pinia)
return mount(AssetBrowserModal, {
props: {
assets: assets,
...props
},
global: {
plugins: [pinia],
stubs: {
'i-lucide:folder': {
template: '<div data-testid="folder-icon"></div>'
}
},
mocks: {
$t: (key: string) => key
}
}
})
}
describe('Search Functionality', () => {
it('filters assets when search query changes', async () => {
const assets = [
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
createTestAsset('asset2', 'Checkpoint Model B', 'checkpoints'),
createTestAsset('asset3', 'LoRA Model C', 'loras')
]
const wrapper = createWrapper(assets)
const searchBox = wrapper.find('[data-testid="search-box"]')
// Search for "Checkpoint"
await searchBox.setValue('Checkpoint')
await nextTick()
// Should filter to only checkpoint assets
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
expect(filteredAssets.length).toBe(2)
expect(
filteredAssets.every((asset: AssetDisplayItem) =>
asset.name.includes('Checkpoint')
)
).toBe(true)
})
it('search is case insensitive', async () => {
const assets = [
createTestAsset('asset1', 'LoRA Model C', 'loras'),
createTestAsset('asset2', 'Checkpoint Model', 'checkpoints')
]
const wrapper = createWrapper(assets)
const searchBox = wrapper.find('[data-testid="search-box"]')
// Search with different case
await searchBox.setValue('lora')
await nextTick()
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
expect(filteredAssets.length).toBe(1)
expect(filteredAssets[0].name).toContain('LoRA')
})
it('shows empty state when search has no results', async () => {
const assets = [
createTestAsset('asset1', 'Checkpoint Model', 'checkpoints')
]
const wrapper = createWrapper(assets)
const searchBox = wrapper.find('[data-testid="search-box"]')
// Search for something that doesn't exist
await searchBox.setValue('nonexistent')
await nextTick()
expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true)
})
})
describe('Category Navigation', () => {
it('filters assets by selected category', async () => {
const assets = [
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
createTestAsset('asset2', 'LoRA Model C', 'loras'),
createTestAsset('asset3', 'VAE Model D', 'vae')
]
const wrapper = createWrapper(assets, { showLeftPanel: true })
// Wait for Vue reactivity and component mounting
await nextTick()
// Check if left panel exists first (since we have multiple categories)
const leftPanel = wrapper.find('[data-testid="left-panel"]')
expect(leftPanel.exists()).toBe(true)
// Check if the nav item exists before clicking
const lorasNavItem = wrapper.find('[data-testid="nav-item-loras"]')
expect(lorasNavItem.exists()).toBe(true)
// Click the loras category
await lorasNavItem.trigger('click')
await nextTick()
// Should filter to only LoRA assets
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
expect(filteredAssets.length).toBe(1)
expect(filteredAssets[0].name).toContain('LoRA')
})
})
describe('Asset Selection', () => {
it('emits asset-select event when asset is selected', async () => {
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
const wrapper = createWrapper(assets)
// Click on first asset
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
const emitted = wrapper.emitted('asset-select')
expect(emitted).toBeDefined()
expect(emitted).toHaveLength(1)
const emittedAsset = emitted![0][0] as AssetDisplayItem
expect(emittedAsset.id).toBe('asset1')
})
it('executes onSelect callback when provided', async () => {
const onSelectSpy = vi.fn()
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
const wrapper = createWrapper(assets, { onSelect: onSelectSpy })
// Click on first asset
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
expect(onSelectSpy).toHaveBeenCalledWith('Test Model')
})
})
describe('Left Panel Conditional Logic', () => {
it('hides left panel by default when showLeftPanel prop is undefined', () => {
const singleCategoryAssets = [
createTestAsset('single1', 'Asset 1', 'checkpoints'),
createTestAsset('single2', 'Asset 2', 'checkpoints')
]
const wrapper = createWrapper(singleCategoryAssets)
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(false)
})
it('shows left panel when showLeftPanel prop is explicitly true', () => {
const singleCategoryAssets = [
createTestAsset('single1', 'Asset 1', 'checkpoints')
]
// Force show even with single category
const wrapper = createWrapper(singleCategoryAssets, {
showLeftPanel: true
})
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(true)
// Force hide even with multiple categories
wrapper.unmount()
const multiCategoryAssets = [
createTestAsset('asset1', 'Checkpoint', 'checkpoints'),
createTestAsset('asset2', 'LoRA', 'loras')
]
const wrapper2 = createWrapper(multiCategoryAssets, {
showLeftPanel: false
})
expect(wrapper2.find('[data-testid="left-panel"]').exists()).toBe(false)
})
})
})

View File

@@ -0,0 +1,138 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
// Mock components with minimal functionality for business logic testing
vi.mock('@/components/input/MultiSelect.vue', () => ({
default: {
name: 'MultiSelect',
props: {
modelValue: Array,
label: String,
options: Array,
class: String
},
emits: ['update:modelValue'],
template: `
<div data-testid="multi-select">
<select multiple @change="$emit('update:modelValue', Array.from($event.target.selectedOptions).map(o => ({ name: o.text, value: o.value })))">
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.name }}
</option>
</select>
</div>
`
}
}))
vi.mock('@/components/input/SingleSelect.vue', () => ({
default: {
name: 'SingleSelect',
props: {
modelValue: String,
label: String,
options: Array,
class: String
},
emits: ['update:modelValue'],
template: `
<div data-testid="single-select">
<select @change="$emit('update:modelValue', $event.target.value)">
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.name }}
</option>
</select>
</div>
`
}
}))
// Test factory functions
describe('AssetFilterBar', () => {
describe('Filter State Management', () => {
it('maintains correct initial state', () => {
const wrapper = mount(AssetFilterBar)
// Test initial state through component props
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
const singleSelect = wrapper.findComponent({ name: 'SingleSelect' })
expect(multiSelects[0].props('modelValue')).toEqual([])
expect(multiSelects[1].props('modelValue')).toEqual([])
expect(singleSelect.props('modelValue')).toBe('name-asc')
})
it('handles multiple simultaneous filter changes correctly', async () => {
const wrapper = mount(AssetFilterBar)
// Update file formats
const fileFormatSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[0]
await fileFormatSelect.vm.$emit('update:modelValue', [
{ name: '.ckpt', value: 'ckpt' },
{ name: '.safetensors', value: 'safetensors' }
])
await nextTick()
// Update base models
const baseModelSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[1]
await baseModelSelect.vm.$emit('update:modelValue', [
{ name: 'SD XL', value: 'sdxl' }
])
await nextTick()
// Update sort
const sortSelect = wrapper.findComponent({ name: 'SingleSelect' })
await sortSelect.vm.$emit('update:modelValue', 'popular')
await nextTick()
const emitted = wrapper.emitted('filterChange')
expect(emitted).toHaveLength(3)
// Check final state
const finalState: FilterState = emitted![2][0] as FilterState
expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors'])
expect(finalState.baseModels).toEqual(['sdxl'])
expect(finalState.sortBy).toBe('popular')
})
it('ensures FilterState interface compliance', async () => {
const wrapper = mount(AssetFilterBar)
const fileFormatSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[0]
await fileFormatSelect.vm.$emit('update:modelValue', [
{ name: '.ckpt', value: 'ckpt' }
])
await nextTick()
const emitted = wrapper.emitted('filterChange')
const filterState = emitted![0][0] as FilterState
// Type and structure assertions
expect(Array.isArray(filterState.fileFormats)).toBe(true)
expect(Array.isArray(filterState.baseModels)).toBe(true)
expect(typeof filterState.sortBy).toBe('string')
// Value type assertions
expect(filterState.fileFormats.every((f) => typeof f === 'string')).toBe(
true
)
expect(filterState.baseModels.every((m) => typeof m === 'string')).toBe(
true
)
})
})
})

View File

@@ -0,0 +1,323 @@
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
describe('useAssetBrowser', () => {
// Test fixtures - minimal data focused on functionality being tested
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
id: 'test-id',
name: 'test-asset.safetensors',
asset_hash: 'blake3:abc123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
...overrides
})
describe('Asset Transformation', () => {
it('transforms API asset to include display properties', () => {
const apiAsset = createApiAsset({
size: 2147483648, // 2GB
user_metadata: { description: 'Test model' }
})
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
const result = transformAssetForDisplay(apiAsset)
// Preserves API properties
expect(result.id).toBe(apiAsset.id)
expect(result.name).toBe(apiAsset.name)
// Adds display properties
expect(result.description).toBe('Test model')
expect(result.formattedSize).toBe('2 GB')
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
expect(result.badges).toContainEqual({ label: '2 GB', type: 'size' })
})
it('creates fallback description from tags when metadata missing', () => {
const apiAsset = createApiAsset({
tags: ['models', 'loras'],
user_metadata: undefined
})
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
const result = transformAssetForDisplay(apiAsset)
expect(result.description).toBe('loras model')
})
it('formats various file sizes correctly', () => {
const { transformAssetForDisplay } = useAssetBrowser([])
const testCases = [
{ size: 512, expected: '512 B' },
{ size: 1536, expected: '1.5 KB' },
{ size: 2097152, expected: '2 MB' },
{ size: 3221225472, expected: '3 GB' }
]
testCases.forEach(({ size, expected }) => {
const asset = createApiAsset({ size })
const result = transformAssetForDisplay(asset)
expect(result.formattedSize).toBe(expected)
})
})
})
describe('Tag-Based Filtering', () => {
it('filters assets by category tag', async () => {
const assets = [
createApiAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createApiAsset({ id: '2', tags: ['models', 'loras'] }),
createApiAsset({ id: '3', tags: ['models', 'checkpoints'] })
]
const { selectedCategory, filteredAssets } = useAssetBrowser(assets)
selectedCategory.value = 'checkpoints'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(
filteredAssets.value.every((asset) =>
asset.tags.includes('checkpoints')
)
).toBe(true)
})
it('returns all assets when category is "all"', async () => {
const assets = [
createApiAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createApiAsset({ id: '2', tags: ['models', 'loras'] })
]
const { selectedCategory, filteredAssets } = useAssetBrowser(assets)
selectedCategory.value = 'all'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
})
})
describe('Search Functionality', () => {
it('searches across asset name', async () => {
const assets = [
createApiAsset({ name: 'realistic_vision.safetensors' }),
createApiAsset({ name: 'anime_style.ckpt' }),
createApiAsset({ name: 'photorealistic_v2.safetensors' })
]
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
searchQuery.value = 'realistic'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(
filteredAssets.value.every((asset) =>
asset.name.toLowerCase().includes('realistic')
)
).toBe(true)
})
it('searches in user metadata description', async () => {
const assets = [
createApiAsset({
name: 'model1.safetensors',
user_metadata: { description: 'fantasy artwork model' }
}),
createApiAsset({
name: 'model2.safetensors',
user_metadata: { description: 'portrait photography' }
})
]
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
searchQuery.value = 'fantasy'
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].name).toBe('model1.safetensors')
})
it('handles empty search results', async () => {
const assets = [createApiAsset({ name: 'test.safetensors' })]
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
searchQuery.value = 'nonexistent'
await nextTick()
expect(filteredAssets.value).toHaveLength(0)
})
})
describe('Combined Search and Filtering', () => {
it('applies both search and category filter', async () => {
const assets = [
createApiAsset({
name: 'realistic_checkpoint.safetensors',
tags: ['models', 'checkpoints']
}),
createApiAsset({
name: 'realistic_lora.safetensors',
tags: ['models', 'loras']
}),
createApiAsset({
name: 'anime_checkpoint.safetensors',
tags: ['models', 'checkpoints']
})
]
const { searchQuery, selectedCategory, filteredAssets } =
useAssetBrowser(assets)
searchQuery.value = 'realistic'
selectedCategory.value = 'checkpoints'
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].name).toBe(
'realistic_checkpoint.safetensors'
)
})
})
describe('Sorting', () => {
it('sorts assets by name', async () => {
const assets = [
createApiAsset({ name: 'zebra.safetensors' }),
createApiAsset({ name: 'alpha.safetensors' }),
createApiAsset({ name: 'beta.safetensors' })
]
const { sortBy, filteredAssets } = useAssetBrowser(assets)
sortBy.value = 'name'
await nextTick()
const names = filteredAssets.value.map((asset) => asset.name)
expect(names).toEqual([
'alpha.safetensors',
'beta.safetensors',
'zebra.safetensors'
])
})
it('sorts assets by creation date', async () => {
const assets = [
createApiAsset({ created_at: '2024-03-01T00:00:00Z' }),
createApiAsset({ created_at: '2024-01-01T00:00:00Z' }),
createApiAsset({ created_at: '2024-02-01T00:00:00Z' })
]
const { sortBy, filteredAssets } = useAssetBrowser(assets)
sortBy.value = 'date'
await nextTick()
const dates = filteredAssets.value.map((asset) => asset.created_at)
expect(dates).toEqual([
'2024-03-01T00:00:00Z',
'2024-02-01T00:00:00Z',
'2024-01-01T00:00:00Z'
])
})
})
describe('Asset Selection', () => {
it('returns selected asset UUID for efficient handling', () => {
const asset = createApiAsset({
id: 'test-uuid-123',
name: 'selected_model.safetensors'
})
const { selectAsset, transformAssetForDisplay } = useAssetBrowser([asset])
const displayAsset = transformAssetForDisplay(asset)
const result = selectAsset(displayAsset)
expect(result).toBe('test-uuid-123')
})
})
describe('Dynamic Category Extraction', () => {
it('extracts categories from asset tags', () => {
const assets = [
createApiAsset({ tags: ['models', 'checkpoints'] }),
createApiAsset({ tags: ['models', 'loras'] }),
createApiAsset({ tags: ['models', 'checkpoints'] }) // duplicate
]
const { availableCategories } = useAssetBrowser(assets)
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--package]'
},
{ id: 'loras', label: 'Loras', icon: 'icon-[lucide--package]' }
])
})
it('handles assets with no category tag', () => {
const assets = [
createApiAsset({ tags: ['models'] }), // No second tag
createApiAsset({ tags: ['models', 'vae'] })
]
const { availableCategories } = useAssetBrowser(assets)
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{ id: 'vae', label: 'Vae', icon: 'icon-[lucide--package]' }
])
})
it('ignores non-models root tags', () => {
const assets = [
createApiAsset({ tags: ['input', 'images'] }),
createApiAsset({ tags: ['models', 'checkpoints'] })
]
const { availableCategories } = useAssetBrowser(assets)
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--package]'
}
])
})
it('computes content title from selected category', () => {
const assets = [createApiAsset({ tags: ['models', 'checkpoints'] })]
const { selectedCategory, contentTitle } = useAssetBrowser(assets)
// Default
expect(contentTitle.value).toBe('All Models')
// Set specific category
selectedCategory.value = 'checkpoints'
expect(contentTitle.value).toBe('Checkpoints')
// Unknown category
selectedCategory.value = 'unknown'
expect(contentTitle.value).toBe('Assets')
})
})
})

View File

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { useDialogStore } from '@/stores/dialogStore'
// Mock the dialog store
vi.mock('@/stores/dialogStore')
// Test factory functions
interface AssetBrowserProps {
nodeType: string
inputName: string
onAssetSelected?: ReturnType<typeof vi.fn>
}
function createAssetBrowserProps(
overrides: Partial<AssetBrowserProps> = {}
): AssetBrowserProps {
return {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
...overrides
}
}
describe('useAssetBrowserDialog', () => {
describe('Asset Selection Flow', () => {
it('auto-closes dialog when asset is selected', () => {
// Create fresh mocks for this test
const mockShowDialog = vi.fn()
const mockCloseDialog = vi.fn()
vi.mocked(useDialogStore).mockReturnValue({
showDialog: mockShowDialog,
closeDialog: mockCloseDialog
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
typeof useDialogStore
>)
const assetBrowserDialog = useAssetBrowserDialog()
const onAssetSelected = vi.fn()
const props = createAssetBrowserProps({ onAssetSelected })
assetBrowserDialog.show(props)
// Get the onSelect handler that was passed to the dialog
const dialogCall = mockShowDialog.mock.calls[0][0]
const onSelectHandler = dialogCall.props.onSelect
// Simulate asset selection
onSelectHandler('selected-asset-path')
// Should call the original callback and close dialog
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'global-asset-browser'
})
})
it('closes dialog when close handler is called', () => {
// Create fresh mocks for this test
const mockShowDialog = vi.fn()
const mockCloseDialog = vi.fn()
vi.mocked(useDialogStore).mockReturnValue({
showDialog: mockShowDialog,
closeDialog: mockCloseDialog
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
typeof useDialogStore
>)
const assetBrowserDialog = useAssetBrowserDialog()
const props = createAssetBrowserProps()
assetBrowserDialog.show(props)
// Get the onClose handler that was passed to the dialog
const dialogCall = mockShowDialog.mock.calls[0][0]
const onCloseHandler = dialogCall.props.onClose
// Simulate dialog close
onCloseHandler()
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'global-asset-browser'
})
})
})
})

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// Test factory functions
function createTestAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'test-uuid',
name: 'test-model.safetensors',
asset_hash: 'blake3:test123',
size: 123456,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
user_metadata: {
base_model: 'sd15'
},
...overrides
}
}
describe('useAssetFilterOptions', () => {
describe('File Format Extraction', () => {
it('extracts file formats from asset names', () => {
const assets = [
createTestAsset({ name: 'model1.safetensors' }),
createTestAsset({ name: 'model2.ckpt' }),
createTestAsset({ name: 'model3.pt' })
]
const { availableFileFormats } = useAssetFilterOptions(assets)
expect(availableFileFormats.value).toEqual([
{ name: '.ckpt', value: 'ckpt' },
{ name: '.pt', value: 'pt' },
{ name: '.safetensors', value: 'safetensors' }
])
})
it('handles duplicate file formats', () => {
const assets = [
createTestAsset({ name: 'model1.safetensors' }),
createTestAsset({ name: 'model2.safetensors' }),
createTestAsset({ name: 'model3.ckpt' })
]
const { availableFileFormats } = useAssetFilterOptions(assets)
expect(availableFileFormats.value).toEqual([
{ name: '.ckpt', value: 'ckpt' },
{ name: '.safetensors', value: 'safetensors' }
])
})
it('handles assets with no file extension', () => {
const assets = [
createTestAsset({ name: 'model_no_extension' }),
createTestAsset({ name: 'model.safetensors' })
]
const { availableFileFormats } = useAssetFilterOptions(assets)
expect(availableFileFormats.value).toEqual([
{ name: '.safetensors', value: 'safetensors' }
])
})
it('handles empty asset list', () => {
const { availableFileFormats } = useAssetFilterOptions([])
expect(availableFileFormats.value).toEqual([])
})
})
describe('Base Model Extraction', () => {
it('extracts base models from user metadata', () => {
const assets = [
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
createTestAsset({ user_metadata: { base_model: 'sdxl' } }),
createTestAsset({ user_metadata: { base_model: 'sd35' } })
]
const { availableBaseModels } = useAssetFilterOptions(assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' },
{ name: 'sd35', value: 'sd35' },
{ name: 'sdxl', value: 'sdxl' }
])
})
it('handles duplicate base models', () => {
const assets = [
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
]
const { availableBaseModels } = useAssetFilterOptions(assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' },
{ name: 'sdxl', value: 'sdxl' }
])
})
it('handles assets with missing user_metadata', () => {
const assets = [
createTestAsset({ user_metadata: undefined }),
createTestAsset({ user_metadata: { base_model: 'sd15' } })
]
const { availableBaseModels } = useAssetFilterOptions(assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' }
])
})
it('handles assets with missing base_model field', () => {
const assets = [
createTestAsset({ user_metadata: { description: 'A test model' } }),
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
]
const { availableBaseModels } = useAssetFilterOptions(assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sdxl', value: 'sdxl' }
])
})
it('handles empty asset list', () => {
const { availableBaseModels } = useAssetFilterOptions([])
expect(availableBaseModels.value).toEqual([])
})
})
describe('Reactivity', () => {
it('returns computed properties that can be reactive', () => {
const assets = [createTestAsset({ name: 'model.safetensors' })]
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
// These should be computed refs
expect(availableFileFormats.value).toBeDefined()
expect(availableBaseModels.value).toBeDefined()
expect(typeof availableFileFormats.value).toBe('object')
expect(typeof availableBaseModels.value).toBe('object')
expect(Array.isArray(availableFileFormats.value)).toBe(true)
expect(Array.isArray(availableBaseModels.value)).toBe(true)
})
})
})

View File

@@ -1,5 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { VueWrapper, mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'

View File

@@ -14,7 +14,7 @@ import {
} from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { TaskLog } from '@/types/comfyManagerTypes'
import type { TaskLog } from '@/types/comfyManagerTypes'
// Mock modules
vi.mock('@/stores/comfyManagerStore')

View File

@@ -2,12 +2,8 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import {
LGraphEventMode,
LGraphNode,
Positionable,
Reroute
} from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useManagerQueue } from '@/composables/useManagerQueue'
import { components } from '@/types/generatedManagerTypes'
import type { components } from '@/types/generatedManagerTypes'
// Mock dialog service
vi.mock('@/services/dialogService', () => ({

View File

@@ -56,10 +56,10 @@ describe('useManagerState', () => {
})
describe('managerUIState property', () => {
it('should return DISABLED state when --disable-manager is present', () => {
it('should return DISABLED state when --enable-manager is NOT present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--disable-manager'] }
system: { argv: ['python', 'main.py'] } // No --enable-manager flag
}),
isInitialized: ref(true)
} as any)
@@ -76,7 +76,14 @@ describe('useManagerState', () => {
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager-legacy-ui'] }
system: {
argv: [
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]
} // Both flags needed
}),
isInitialized: ref(true)
} as any)
@@ -92,7 +99,9 @@ describe('useManagerState', () => {
it('should return NEW_UI state when client and server both support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({ system: { argv: ['python', 'main.py'] } }),
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
@@ -114,7 +123,9 @@ describe('useManagerState', () => {
it('should return LEGACY_UI state when server supports v4 but client does not', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({ system: { argv: ['python', 'main.py'] } }),
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
@@ -136,7 +147,9 @@ describe('useManagerState', () => {
it('should return LEGACY_UI state when legacy manager extension exists', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({ system: { argv: ['python', 'main.py'] } }),
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
@@ -155,7 +168,9 @@ describe('useManagerState', () => {
it('should return NEW_UI state when server feature flags are undefined', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({ system: { argv: ['python', 'main.py'] } }),
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
@@ -175,7 +190,9 @@ describe('useManagerState', () => {
it('should return LEGACY_UI state when server does not support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({ system: { argv: ['python', 'main.py'] } }),
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
@@ -212,14 +229,17 @@ describe('useManagerState', () => {
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
// When systemStats is null, we can't check for --enable-manager flag, so manager is disabled
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
})
describe('helper properties', () => {
it('isManagerEnabled should return true when state is not DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({ system: { argv: ['python', 'main.py'] } }),
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
@@ -237,7 +257,7 @@ describe('useManagerState', () => {
it('isManagerEnabled should return false when state is DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--disable-manager'] }
system: { argv: ['python', 'main.py'] } // No --enable-manager flag means disabled
}),
isInitialized: ref(true)
} as any)
@@ -252,7 +272,9 @@ describe('useManagerState', () => {
it('isNewManagerUI should return true when state is NEW_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({ system: { argv: ['python', 'main.py'] } }),
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
@@ -270,7 +292,14 @@ describe('useManagerState', () => {
it('isLegacyManagerUI should return true when state is LEGACY_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager-legacy-ui'] }
system: {
argv: [
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]
} // Both flags needed
}),
isInitialized: ref(true)
} as any)
@@ -285,7 +314,9 @@ describe('useManagerState', () => {
it('shouldShowInstallButton should return true only for NEW_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({ system: { argv: ['python', 'main.py'] } }),
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
@@ -302,7 +333,9 @@ describe('useManagerState', () => {
it('shouldShowManagerButtons should return true when not DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({ system: { argv: ['python', 'main.py'] } }),
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget',

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useServerLogs } from '@/composables/useServerLogs'
import { LogsWsMessage } from '@/schemas/apiSchema'
import type { LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
vi.mock('@/scripts/api', () => ({

View File

@@ -5,7 +5,7 @@ import { LinkConnector } from '@/lib/litegraph/src/litegraph'
import { MovingOutputLink } from '@/lib/litegraph/src/litegraph'
import { ToOutputRenderLink } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import type { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '../subgraph/fixtures/subgraphHelpers'

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
describe('LGraphNode Title Buttons', () => {

View File

@@ -1,13 +1,12 @@
// TODO: Fix these tests after migration
import { afterEach, describe, expect, vi } from 'vitest'
import type { LGraph, Reroute } from '@/lib/litegraph/src/litegraph'
import {
type CanvasPointerEvent,
LGraph,
LGraphNode,
LLink,
LinkConnector,
Reroute,
type RerouteId
} from '@/lib/litegraph/src/litegraph'

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { INodeInputSlot, INodeOutputSlot } from '@/lib/litegraph/src/litegraph'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/litegraph'
import {
inputAsSerialisable,
outputAsSerialisable

View File

@@ -1,9 +1,8 @@
// TODO: Fix these tests after migration
import { assert, describe, expect, it } from 'vitest'
import type { ISlotType, LGraph } from '@/lib/litegraph/src/litegraph'
import {
ISlotType,
LGraph,
LGraphGroup,
LGraphNode,
LiteGraph

View File

@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,

View File

@@ -5,8 +5,8 @@ import { LinkConnector } from '@/lib/litegraph/src/litegraph'
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/litegraph'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, type LinkNetwork } from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import { NodeOutputSlot } from '@/lib/litegraph/src/litegraph'
import type { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import type { NodeOutputSlot } from '@/lib/litegraph/src/litegraph'
import {
isSubgraphInput,
isSubgraphOutput

View File

@@ -1,8 +1,8 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import type { ISlotType, Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { TWidgetType } from '@/lib/litegraph/src/litegraph'
import { BaseWidget } from '@/lib/litegraph/src/litegraph'

View File

@@ -5,8 +5,9 @@
* in their test files. Each fixture provides a clean, pre-configured subgraph
* setup for different testing scenarios.
*/
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { test } from '../../core/fixtures/testExtensions'
import {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { NodeSearchService } from '@/services/nodeSearchService'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModel,
getAssetDescription
} from '@/platform/assets/utils/assetMetadataUtils'
describe('assetMetadataUtils', () => {
const mockAsset: AssetItem = {
id: 'test-id',
name: 'test-model',
asset_hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z'
}
describe('getAssetDescription', () => {
it('should return string description when present', () => {
const asset = {
...mockAsset,
user_metadata: { description: 'A test model' }
}
expect(getAssetDescription(asset)).toBe('A test model')
})
it('should return null when description is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { description: 123 }
}
expect(getAssetDescription(asset)).toBeNull()
})
it('should return null when no metadata', () => {
expect(getAssetDescription(mockAsset)).toBeNull()
})
})
describe('getAssetBaseModel', () => {
it('should return string base_model when present', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 'SDXL' }
}
expect(getAssetBaseModel(asset)).toBe('SDXL')
})
it('should return null when base_model is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 123 }
}
expect(getAssetBaseModel(asset)).toBeNull()
})
it('should return null when no metadata', () => {
expect(getAssetBaseModel(mockAsset)).toBeNull()
})
})
})

View File

@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
// Mock stores
vi.mock('@/renderer/core/canvas/canvasStore', () => {

View File

@@ -1,10 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
@@ -12,10 +13,18 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn()
}))
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
useLayoutMutations: vi.fn()
}))
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
useGraphNodeManager: vi.fn()
}))
function createMockCanvas(): Pick<
LGraphCanvas,
'select' | 'deselect' | 'deselectAll'
@@ -68,12 +77,22 @@ function createMockLayoutMutations(): Pick<
}
}
function createMockCanvasInteractions(): Pick<
ReturnType<typeof useCanvasInteractions>,
'shouldHandleNodePointerEvents'
> {
return {
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
}
}
describe('useNodeEventHandlers', () => {
let mockCanvas: ReturnType<typeof createMockCanvas>
let mockNode: ReturnType<typeof createMockNode>
let mockNodeManager: ReturnType<typeof createMockNodeManager>
let mockCanvasStore: ReturnType<typeof createMockCanvasStore>
let mockLayoutMutations: ReturnType<typeof createMockLayoutMutations>
let mockCanvasInteractions: ReturnType<typeof createMockCanvasInteractions>
const testNodeData: VueNodeData = {
id: 'node-1',
@@ -90,6 +109,7 @@ describe('useNodeEventHandlers', () => {
mockNodeManager = createMockNodeManager(mockNode)
mockCanvasStore = createMockCanvasStore(mockCanvas)
mockLayoutMutations = createMockLayoutMutations()
mockCanvasInteractions = createMockCanvasInteractions()
vi.mocked(useCanvasStore).mockReturnValue(
mockCanvasStore as ReturnType<typeof useCanvasStore>
@@ -97,6 +117,9 @@ describe('useNodeEventHandlers', () => {
vi.mocked(useLayoutMutations).mockReturnValue(
mockLayoutMutations as ReturnType<typeof useLayoutMutations>
)
vi.mocked(useCanvasInteractions).mockReturnValue(
mockCanvasInteractions as ReturnType<typeof useCanvasInteractions>
)
})
describe('handleNodeSelect', () => {

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