Compare commits

..

27 Commits

Author SHA1 Message Date
snomiao
ce71c2c529 feat: Add more Storybook stories for UI components 2025-09-22 21:10:30 +00:00
Alexander Brown
e5d4d07d32 Refactor: More state management simplification (#5721)
## Summary

Remove more procedural synchronization in favor of using reactive
references.

> Note: Also includes some fixes for issues caused during HMR.

## Review Focus

In testing it seems to work the same, but let me know if I missed
something.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5721-Refactor-More-state-management-simplification-2766d73d3650819b8d7ddc047c460f2b)
by [Unito](https://www.unito.io)
2025-09-22 13:15:33 -07:00
Alexander Piskun
f086377307 add pricing for new api nodes (#5724)
## Summary

Added prices for the new upcoming API nodes. Backport required.
2025-09-22 11:33:00 -07:00
filtered
687b9e659c Fix reroute ID 0 treated as invalid (#5723)
## Summary

Fixes old logic bug from refactor
https://github.com/Comfy-Org/litegraph.js/pull/602/files

## Changes

- Fixes truthy refactor to explicitly check undefined

## Review Focus

No expectation that this will impact prod, however it may impact
extensions IF someone has explicitly been setting link parentId to 0.
This would be very strange, as it would cause unexpected behaviour in
other parts of the code (which all explicitly check `undefined`).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5723-Fix-reroute-ID-0-treated-as-invalid-2766d73d365081568124ce1f85cdf84e)
by [Unito](https://www.unito.io)
2025-09-22 11:13:38 -07:00
Christian Byrne
da0d51311b fix Vue node being dragged when interacting with widgets (e.g., resizing textarea) (#5719)
## Summary

Applying changes in
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5516 to entire widget
wrapper.
 
## Changes

- **What**: Added `.stop` modifier to pointer events in NodeWidgets
component to prevent [event
propagation](https://developer.mozilla.org/en-US/docs/Web/API/Event/stopPropagation)

## Review Focus

Verify widget interactions remain functional while ensuring parent node
drag/selection behavior is properly isolated.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5719-fix-Vue-node-being-dragged-when-interacting-with-widgets-e-g-resizing-textarea-2766d73d3650815091adcd1d65197c7b)
by [Unito](https://www.unito.io)
2025-09-21 21:56:03 -07:00
Christian Byrne
e314d9cbd9 [refactor] Simplify current user resolved hook implementation (#5718)
## Summary

Refactored `onUserResolved` function in auth composable to use VueUse
`whenever` utility instead of manual watch implementation and use
`immediate` option instead of invoking manually before creating watcher.

## Changes

- **What**: Replaced manual watch + immediate check pattern with [VueUse
whenever](https://vueuse.org/shared/whenever/) utility in
`useCurrentUser.ts:37`

## Review Focus

Behavioral equivalence verification - `whenever` with `immediate: true`
should maintain identical callback timing and cleanup semantics.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5718-refactor-Simplify-current-user-resolved-hook-implementation-2766d73d365081008b6de156dd78f940)
by [Unito](https://www.unito.io)
2025-09-21 21:53:25 -07:00
Christian Byrne
95baf8d2f1 [style] update Vue node tooltip style (#5717)
## Summary

Change Vue node tooltips to align with
[design](https://www.figma.com/design/31uH3r4x3xbIctuRWYW6NM/V3---Nodes?node-id=6267-16837&m=dev)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5717-style-update-Vue-node-tooltip-style-2766d73d365081bdb095faef17f6aeb6)
by [Unito](https://www.unito.io)
2025-09-21 20:01:33 -07:00
Christian Byrne
f951e07cea fix bypass hotkey in vue nodes and fix node data instrumentation setup issue when switching to Vue nodes after initial load (#5715)
## Summary

Fixed Vue node keybinding target element ID to enable
bypass/pin/collapse hotkeys in both LiteGraph and Vue rendering modes.

Also fixed a bug when starting in litegraph mode => switching to Vue
nodes without reloading => `graph.onTrigger` is set to `undefined` which
interferes with proper setup of node data instrumentation, among other
things.

## Changes

- **What**: Updated keybinding `targetElementId` from `graph-canvas` to
`graph-canvas-container` for node manipulation commands (parent of both
the canvas and transform pane -- vue nodes container).
- **What**: Added conditional `onTrigger` handler restoration in slot
layout sync to prevent Vue node manager conflicts

## Review Focus

Event handler precedence between Vue nodes and LiteGraph systems during
mode switching, ensuring hotkeys work consistently across rendering
modes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5715-fix-bypass-hotkey-in-vue-nodes-and-fix-node-data-instrumentation-setup-issue-when-switchi-2756d73d3650815c8ec8d5e4d06232e3)
by [Unito](https://www.unito.io)
2025-09-21 17:32:12 -07:00
Christian Byrne
023e466dba fix using shift modifier to (de-)select Vue nodes (#5714)
## Summary

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/5688 by
adding shift modifier support for multi-selecting Vue nodes, enabling
standard shift+click selection behavior alongside existing
ctrl/cmd+click.

## Changes

- **What**: Updated Vue node event handlers to include `event.shiftKey`
in multi-select logic
- **Testing**: Added browser tests for both ctrl and shift modifier
selection behaviors

## Review Focus

Multi-select behavior consistency across different input modifiers and
platform compatibility (Windows/Mac/Linux shift key handling).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5714-fix-using-shift-modifier-to-de-select-Vue-nodes-2756d73d365081bcb5e0fe80eacdb2f0)
by [Unito](https://www.unito.io)
2025-09-21 14:39:40 -07:00
Christian Byrne
abd6823744 [refactor] Remove redundant module comment (#5711)
Removes a comment added in initial Vue Nodes commit. The comment is
interpolated between import statements which is stylistically awkward
and it is almost totally redundant with the doc comment on the
composable:


c1d4709e96/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts (L10-L14)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5711-refactor-Remove-redundant-module-comment-2756d73d365081ef9bffe0257b3670f1)
by [Unito](https://www.unito.io)
2025-09-21 14:30:58 -07:00
Alexander Brown
c4c0e52e64 Refactor: Let LGraphNode handle more events itself (#5709)
## Summary

Don't route events up through GraphCanvas if the component itself can
handle the changes

## Changes

- **What**: Reduce the indirect access or action dispatch to
composables/stores.

## Review Focus

The behavior should be either equivalent or a little snappier than
before. Also, the local state in LGraphNode has (almost) all been
removed in favor of reacting to the nodeData prop.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5709-Refactor-Let-LGraphNode-handle-more-events-itself-2756d73d365081e6a88ce6241bceecc0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-20 22:14:30 -07:00
Christian Byrne
295332dc46 update CODEOWNERS (#5667)
Add explicit CODEOWNERS for new features to allow more domain-driven
review/approval/ownership processes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5667-update-CODEOWNERS-2736d73d3650817ea52be9c4a8fe5ff2)
by [Unito](https://www.unito.io)
2025-09-20 20:11:35 -07:00
Christian Byrne
5c498348b8 fix: update to standardized mobile web app meta tag syntax (#5672)
## Summary

Fixed WebKit deprecation warning by updating to standardized mobile web
app meta tag syntax.

## Changes

- **What**: Replaced deprecated `apple-mobile-web-app-capable` with
cross-platform
[`mobile-web-app-capable`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name#mobile-web-app-capable)
meta tag to align with WebKit's move toward vendor-neutral standards

## Review Focus

Verify "Add to Home Screen" functionality still works on iOS/iPadOS and
that the WebKit console warning is resolved in production builds.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5672-fix-update-to-standardized-mobile-web-app-meta-tag-syntax-2736d73d3650811cb2a1f0b14ce0a0e7)
by [Unito](https://www.unito.io)
2025-09-20 20:10:51 -07:00
Alexander Brown
8133bd4b7b Refactor: Composable disentangling (#5695)
## Summary

Prerequisite refactor/cleanup to use a global store instead of having
nodes throw up events to a parent component that stores a reference to a
singleton service that itself bootstraps and synchronizes with a
separate service to maintain a partially reactive but not fully reactive
set of states that describe some but not all aspects of the nodes on
either the litegraph, the vue side, or both.

## Changes

- **What**: Refactoring, the behavior should not change.
- **Dependencies**: A type utility to help with Vue component props

## Review Focus

Is there something about the current structure that this could affect
that would not be caught by our tests or using the application?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5695-Refactor-Composable-disentangling-2746d73d365081e6938ce656932f3e36)
by [Unito](https://www.unito.io)
2025-09-20 13:06:42 -07:00
Arjan Singh
fd12591756 [feat] integrate asset browser with widget system (#5629)
## Summary

Add asset browser dialog integration for combo widgets with full
animation support and proper state management.

(Thank you Claude from saving me me from merge conflict hell on this
one.)

## Changes

- Widget integration: combo widgets now use AssetBrowserModal for
eligible asset types
- Dialog animations: added animateHide() for smooth close transitions
- Async operations: proper sequencing of widget updates and dialog
animations
- Service layer: added getAssetsForNodeType() and getAssetDetails()
methods
- Type safety: comprehensive TypeScript types and error handling
- Test coverage: unit tests for all new functionality
- Bonus: fixed the hardcoded labels in AssetFilterBar

Widget behavior:
- Shows asset browser button for eligible widgets when asset API enabled
- Handles asset selection with proper callback sequencing
- Maintains widget value updates and litegraph notification

## Review Focus

I will call out some stuff inline.

## Screenshots


https://github.com/user-attachments/assets/9d3a72cf-d2b0-445f-8022-4c49daa04637

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5629-feat-integrate-asset-browser-with-widget-system-2726d73d365081a9a98be9a2307aee0b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-20 11:44:18 -07:00
Christian Byrne
b3c939ff15 fix: add Safari requestIdleCallback polyfill (#5664)
## Summary

Implemented cross-browser requestIdleCallback polyfill to fix Safari
crashes during graph initialization.

## Changes

- **What**: Added
[requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
polyfill following [VS Code's
pattern](https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts)
with setTimeout fallback for Safari
- **Breaking**: None - maintains existing GraphView behavior

## Review Focus

Safari compatibility testing and timeout handling in the 15ms fallback
window. Verify that initialization tasks (keybindings, server config,
model loading) still execute properly on Safari iOS.

## References

- [VS Code async.ts
implementation](https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts)
- Source pattern for our polyfill
- [MDN
requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback)
- Browser API documentation
- [Safari requestIdleCallback
support](https://caniuse.com/requestidlecallback) - Browser
compatibility table

Fixes CLOUD-FRONTEND-STAGING-N

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5664-fix-add-Safari-requestIdleCallback-polyfill-2736d73d365081cdbcf1fb816fe098d6)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 23:34:15 -07:00
Christian Byrne
0801778f60 feat: Add Vue node subgraph title button and fix subgraph navigation with vue nodes (#5572)
## Summary
- Adds subgraph title button to Vue node headers (matching LiteGraph
behavior)
- Fixes Vue node lifecycle issues during subgraph navigation and tab
switching
- Extracts reusable `useSubgraphNavigation` composable with
callback-based API
- Adds comprehensive tests for subgraph functionality
- Ensures proper graph context restoration during tab switches



https://github.com/user-attachments/assets/fd4ff16a-4071-4da6-903f-b2be8dd6e672



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5572-feat-Add-Vue-node-subgraph-title-button-with-lifecycle-management-26f6d73d365081bfbd9cfd7d2775e1ef)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-19 14:19:06 -07:00
Johnpaul Chiwetelu
8ffe63f54e Layoutstore Minimap calculation (#5547)
This pull request refactors the minimap rendering system to use a
unified, extensible data source abstraction for all minimap operations.
By introducing a data source interface and factory, the minimap can now
seamlessly support multiple sources of node layout (such as the
`LayoutStore` or the underlying `LiteGraph`), improving maintainability
and future extensibility. Rendering logic and change detection
throughout the minimap have been updated to use this new abstraction,
resulting in cleaner code and easier support for new data models.

**Core architecture improvements:**

* Introduced a new `IMinimapDataSource` interface and related data types
(`MinimapNodeData`, `MinimapLinkData`, `MinimapGroupData`) to
standardize node, link, and group data for minimap rendering.
* Added an abstract base class `AbstractMinimapDataSource` that provides
shared logic for bounds and group/link extraction, and implemented two
concrete data sources: `LiteGraphDataSource` (for classic graph data)
and `LayoutStoreDataSource` (for layout store data).
[[1]](diffhunk://#diff-ea46218fc9ffced84168a5ff975e4a30e43f7bf134ee8f02ed2eae66efbb729dR1-R95)
[[2]](diffhunk://#diff-9a6b7c6be25b4dbeb358fea18f3a21e78797058ccc86c818ed1e5f69c7355273R1-R30)
[[3]](diffhunk://#diff-f200ba9495a03157198abff808ed6c3761746071404a52adbad98f6a9d01249bR1-R42)
* Created a `MinimapDataSourceFactory` that selects the appropriate data
source based on the presence of layout store data, enabling seamless
switching between data models.

**Minimap rendering and logic refactoring:**

* Updated all minimap rendering functions (`renderGroups`,
`renderNodes`, `renderConnections`) and the main `renderMinimapToCanvas`
entry point to use the unified data source interface, significantly
simplifying the rendering code and decoupling it from the underlying
graph structure.
[[1]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L1-R11)
[[2]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R33-R75)
[[3]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L66-R124)
[[4]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L134-R161)
[[5]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L153-R187)
[[6]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L187-L188)
[[7]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R227-R231)
[[8]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L230-R248)
* Refactored minimap viewport and graph change detection logic to use
the data source abstraction for bounds, node, and link change detection,
and to respond to layout store version changes.
[[1]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L2-R10)
[[2]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R33-R35)
[[3]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L99-R141)
[[4]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R157-R160)
[[5]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L8-R11)
[[6]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L56-R64)

These changes make the minimap codebase more modular and robust, and lay
the groundwork for supporting additional node layout strategies in the
future.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5547-Layoutstore-Minimap-calculation-26e6d73d3650813e9457c051dff41ca1)
by [Unito](https://www.unito.io)
2025-09-19 13:52:57 -07:00
Benjamin Lu
893409dfc8 Add playwright tests for links and slots in vue nodes mode (#5668)
Tests added
- Should show a link dragging out from a slot when dragging on a slot
- Should create a link when dropping on a compatible slot
- Should not create a link when dropping on an incompatible slot(s)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5668-Add-playwright-tests-for-links-and-slots-in-vue-nodes-mode-2736d73d36508188a47dceee5d1a11e5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-09-19 13:51:47 -07:00
Christian Byrne
df2fda6077 [refactor] Replace manual semantic version utilities/functions with semver package (#5653)
## Summary
- Replace custom `compareVersions()` with `semver.compare()`
- Replace custom `isSemVer()` with `semver.valid()`  
- Remove deprecated version comparison functions from `formatUtil.ts`
- Update all version comparison logic across components and stores
- Fix tests to use semver mocking instead of formatUtil mocking

## Benefits
- **Industry standard**: Uses well-maintained, battle-tested `semver`
package
- **Better reliability**: Handles edge cases more robustly than custom
implementation
- **Consistent behavior**: All version comparisons now use the same
underlying logic
- **Type safety**: Better TypeScript support with proper semver types


Fixes #4787

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5653-refactor-Replace-manual-semantic-version-utilities-functions-with-semver-package-2736d73d365081fb8498ee11cbcc10e2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 12:27:49 -07:00
Christian Byrne
4f5bbe0605 [refactor] Remove legacy manager UI support and tag from header (#5665)
## Summary

Removed the informational "Use Legacy UI" tag from the ManagerHeader
component while preserving all underlying legacy manager functionality.

## Changes

- **What**: Removed Tag component displaying legacy UI information from
ManagerHeader
- **Breaking**: None - all legacy manager functionality remains intact
- **Dependencies**: None

## Review Focus

Visual cleanup only - the `--enable-manager-legacy-ui` CLI flag and all
related functionality continues to work normally. Only the informational
UI tag has been removed from the header.
2025-09-19 02:07:51 -07:00
Christian Byrne
a975e50f1b [feat] Add tooltip support for Vue nodes (#5577)
## Summary

Added tooltip support for Vue node components using PrimeVue's v-tooltip
directive with proper data integration and container scoping.


https://github.com/user-attachments/assets/d1af31e6-ef6a-4df8-8de4-5098aa4490a1

## Changes

- **What**: Implemented tooltip functionality for Vue node headers,
input/output slots, and widgets using [PrimeVue
v-tooltip](https://primevue.org/tooltip/) directive
- **Dependencies**: Leverages existing PrimeVue tooltip system, no new
dependencies

## Review Focus

Container scoping implementation via provide/inject pattern for tooltip
positioning, proper TypeScript interfaces eliminating `as any` casts,
and integration with existing settings store for tooltip delays and
enable/disable functionality.

```mermaid
graph TD
    A[LGraphNode Container] --> B[provide tooltipContainer]
    B --> C[NodeHeader inject]
    B --> D[InputSlot inject]
    B --> E[OutputSlot inject]
    B --> F[NodeWidgets inject]

    G[useNodeTooltips composable] --> H[NodeDefStore lookup]
    G --> I[Settings integration]
    G --> J[i18n fallback]

    C --> G
    D --> G
    E --> G
    F --> G

    style A fill:#f9f9f9,stroke:#333,color:#000
    style G fill:#e8f4fd,stroke:#0066cc,color:#000
```

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-19 01:07:50 -07:00
Christian Byrne
a17c74fa0c fix: add optional chaining to nodeDef access in NodeTooltip (#5663)
## Summary

Extension of https://github.com/Comfy-Org/ComfyUI_frontend/pull/5659:
Added optional chaining to NodeTooltip component to prevent TypeError
when `nodeDef` is undefined for unknown node types.

## Changes

- **What**: Added [optional chaining
operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining)
(`?.`) to safely access `nodeDef` properties in NodeTooltip component

## Review Focus

Error handling for node types not found in nodeDefStore and tooltip
display behavior for unrecognized nodes.

Fixes CLOUD-FRONTEND-STAGING-3N

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-19 01:04:12 -07:00
Christian Byrne
5e625a5002 [test] add Vue FormSelectButton widget component tests (#5576)
## Summary

Added comprehensive component tests for FormSelectButton widget with 497
test cases covering all interaction patterns and edge cases.

## Changes

- **What**: Created test suite for
[FormSelectButton.vue](https://vuejs.org/guide/scaling-up/testing.html)
component with full coverage of string/number/object options, PrimeVue
compatibility, disabled states, and visual styling
- **Dependencies**: No new dependencies (uses existing vitest,
@vue/test-utils)

## Review Focus

Test completeness covering edge cases like unicode characters, duplicate
values, and objects with missing properties. Verify test helper
functions correctly simulate user interactions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5576-Add-Vue-FormSelectButton-widget-component-tests-26f6d73d36508171ae08ee74d0605db2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
2025-09-19 00:12:25 -07:00
Christian Byrne
002fac0232 [refactor] Migrate manager code to DDD structure (#5662)
## Summary

Reorganized custom nodes manager functionality from scattered technical
layers into a cohesive domain-focused module following [domain-driven
design](https://en.wikipedia.org/wiki/Domain-driven_design) principles.

## Changes

- **What**: Migrated all manager code from technical layers
(`src/components/`, `src/stores/`, etc.) to unified domain structure at
`src/workbench/extensions/manager/`
- **Breaking**: Import paths changed for all manager-related modules
(40+ files updated)

## Review Focus

Verify all import path updates are correct and no circular dependencies
introduced. Check that [Vue 3 composition
API](https://vuejs.org/guide/reusability/composables.html) patterns
remain consistent across relocated composables.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5662-refactor-Migrate-manager-code-to-DDD-structure-2736d73d3650812c87faf6ed0fffb196)
by [Unito](https://www.unito.io)
2025-09-19 00:03:05 -07:00
Christian Byrne
7e115543fa fix: prevent TypeError when nodeDef is undefined in NodeTooltip (#5659)
## Summary

Fix TypeError in NodeTooltip component when `nodeDef` is undefined. This
occurs when hovering over nodes whose type is not found in the
nodeDefStore.

## Changes

- Add optional chaining (`?.`) to `nodeDef.description` access on line
71
- Follows the same defensive pattern used in previous fixes for similar
issues

## Context

This addresses Sentry issue
[CLOUD-FRONTEND-STAGING-1B](https://comfy-org.sentry.io/issues/6829258525/)
which shows 19 occurrences affecting 14 users.

The fix follows the same pattern as previous commits:
-
[290bf52fc](290bf52fc5)
- Fixed similar issue on line 112
-
[e8997a765](e8997a7653)
- Fixed multiple similar issues


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5659-fix-prevent-TypeError-when-nodeDef-is-undefined-in-NodeTooltip-2736d73d3650816e8be3f44889198b58)
by [Unito](https://www.unito.io)
2025-09-18 23:53:58 -07:00
Christian Byrne
80d75bb164 fix TypeError: nodes is not iterable when loading graph (#5660)
## Summary
- Fixes Sentry issue CLOUD-FRONTEND-STAGING-29 (TypeError: nodes is not
iterable)
- Adds defensive guard to check if nodes is valid array before iteration
- Gracefully handles malformed workflow data by skipping node processing

## Root Cause
The `collectMissingNodesAndModels` function in `src/scripts/app.ts:1135`
was attempting to iterate over `nodes` without checking if it was a
valid iterable, causing crashes when workflow data was malformed or
missing the nodes property.

## Fix
Added null/undefined/array validation before the for-loop:
```typescript
if (\!nodes || \!Array.isArray(nodes)) {
  console.warn('Workflow nodes data is missing or invalid, skipping node processing', { nodes, path })
  return
}
```

Fixes CLOUD-FRONTEND-STAGING-29

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5660-fix-TypeError-nodes-is-not-iterable-when-loading-graph-2736d73d365081cfb828d27e59a4811c)
by [Unito](https://www.unito.io)
2025-09-18 23:27:42 -07:00
432 changed files with 6630 additions and 18791 deletions

View File

@@ -67,9 +67,9 @@ This is critical for better file inspection:
Use git locally for much faster analysis:
1. Get list of changed files: `git diff --name-only "$BASE_SHA" > changed_files.txt`
2. Get the full diff: `git diff "$BASE_SHA" > pr_diff.txt`
3. Get detailed file changes with status: `git diff --name-status "$BASE_SHA" > file_changes.txt`
1. Get list of changed files: `git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt`
2. Get the full diff: `git diff "origin/$BASE_BRANCH" > pr_diff.txt`
3. Get detailed file changes with status: `git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt`
### Step 1.5: Create Analysis Cache

View File

@@ -4,25 +4,10 @@ on:
pull_request_target:
types: [closed, labeled]
branches: [main]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to backport'
required: true
type: string
force_rerun:
description: 'Force rerun even if backports exist'
required: false
type: boolean
default: false
jobs:
backport:
if: >
(github.event_name == 'pull_request_target' &&
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'needs-backport')) ||
github.event_name == 'workflow_dispatch'
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'needs-backport')
runs-on: ubuntu-latest
permissions:
contents: write
@@ -30,35 +15,6 @@ jobs:
issues: write
steps:
- name: Validate inputs for manual triggers
if: github.event_name == 'workflow_dispatch'
run: |
# Validate PR number format
if ! [[ "${{ inputs.pr_number }}" =~ ^[0-9]+$ ]]; then
echo "::error::Invalid PR number format. Must be a positive integer."
exit 1
fi
# Validate PR exists and is merged
if ! gh pr view "${{ inputs.pr_number }}" --json merged >/dev/null 2>&1; then
echo "::error::PR #${{ inputs.pr_number }} not found or inaccessible."
exit 1
fi
MERGED=$(gh pr view "${{ inputs.pr_number }}" --json merged --jq '.merged')
if [ "$MERGED" != "true" ]; then
echo "::error::PR #${{ inputs.pr_number }} is not merged. Only merged PRs can be backported."
exit 1
fi
# Validate PR has needs-backport label
if ! gh pr view "${{ inputs.pr_number }}" --json labels --jq '.labels[].name' | grep -q "needs-backport"; then
echo "::error::PR #${{ inputs.pr_number }} does not have 'needs-backport' label."
exit 1
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository
uses: actions/checkout@v4
with:
@@ -73,7 +29,7 @@ jobs:
id: check-existing
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Check for existing backport PRs for this PR number
EXISTING_BACKPORTS=$(gh pr list --state all --search "backport-${PR_NUMBER}-to" --json title,headRefName,baseRefName | jq -r '.[].headRefName')
@@ -83,13 +39,6 @@ jobs:
exit 0
fi
# For manual triggers with force_rerun, proceed anyway
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.force_rerun }}" = "true" ]; then
echo "skip=false" >> $GITHUB_OUTPUT
echo "::warning::Force rerun requested - existing backports will be updated"
exit 0
fi
echo "Found existing backport PRs:"
echo "$EXISTING_BACKPORTS"
echo "skip=true" >> $GITHUB_OUTPUT
@@ -101,17 +50,8 @@ jobs:
run: |
# Extract version labels (e.g., "1.24", "1.22")
VERSIONS=""
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# For manual triggers, get labels from the PR
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else
# For automatic triggers, extract from PR event
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
LABELS=$(echo "$LABELS" | jq -r '.[].name')
fi
for label in $LABELS; do
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
for label in $(echo "$LABELS" | jq -r '.[].name'); do
# Match version labels like "1.24" (major.minor only)
if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
# Validate the branch exists before adding to list
@@ -135,20 +75,12 @@ jobs:
if: steps.check-existing.outputs.skip != 'true'
id: backport
env:
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha }}
run: |
FAILED=""
SUCCESS=""
# Get PR data for manual triggers
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,mergeCommit)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
else
PR_TITLE="${{ github.event.pull_request.title }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
for version in ${{ steps.versions.outputs.versions }}; do
echo "::group::Backporting to core/${version}"
@@ -201,18 +133,10 @@ jobs:
if: steps.check-existing.outputs.skip != 'true' && steps.backport.outputs.success
env:
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
# Get PR data for manual triggers
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author)
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
else
PR_TITLE="${{ github.event.pull_request.title }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
fi
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r version branch <<< "${backport}"
@@ -241,16 +165,9 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json author,mergeCommit)
PR_NUMBER="${{ inputs.pr_number }}"
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
else
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r version reason conflicts <<< "${failure}"

View File

@@ -88,8 +88,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Build types
run: pnpm build:types
@@ -133,7 +131,7 @@ jobs:
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}" --no-git-checks
run: pnpm publish --access public --tag "${{ inputs.dist_tag }}"
working-directory: dist
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

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

@@ -9,7 +9,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

View File

@@ -15,32 +15,21 @@ 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
// 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'))
config.plugins = config.plugins.filter((plugin: any) => {
if (plugin && plugin.name && plugin.name.includes('import-map')) {
return false
}
return true
})
}
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, StoryContext, StoryFn } from '@storybook/vue3-vite'
import type { Preview } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -9,9 +9,11 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import '@/assets/css/style.css'
import { i18n } from '@/i18n'
import '@/lib/litegraph/public/css/litegraph.css'
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'
const ComfyUIPreset = definePreset(Aura, {
semantic: {
@@ -23,11 +25,13 @@ 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: {
@@ -46,8 +50,8 @@ setup((app) => {
app.use(ToastService)
})
// Theme and dialog decorator
export const withTheme = (Story: StoryFn, context: StoryContext) => {
// Dark theme decorator
export const withTheme = (Story: any, context: any) => {
const theme = context.globals.theme || 'light'
// Apply theme class to document root
@@ -59,9 +63,8 @@ export const withTheme = (Story: StoryFn, context: StoryContext) => {
document.body.classList.remove('dark-theme')
}
return Story(context.args, context)
return Story()
}
const preview: Preview = {
parameters: {
controls: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,9 @@ 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(
...rest: ConstructorParameters<typeof window.WebSocket>
) {
super(...rest)
constructor() {
// @ts-expect-error
super(...arguments)
store[this.url] = this
}
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import { Locator, Page } from '@playwright/test'
import path from 'path'
import type {
import {
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
await comfyPage.page.route('**/api/prompt', async (route, req) => {
comfyPage.page.route('**/api/prompt', async (route, req) => {
await new Promise((r) => setTimeout(r, 100))
await route.fulfill({
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,5 +1,4 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { Page, expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'

View File

@@ -1,5 +1,4 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import { Locator, 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 type { SettingParams } from '../../src/platform/settings/types'
import { SettingParams } from '../../src/platform/settings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Topbar commands', () => {

View File

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

View File

@@ -1,13 +1,12 @@
import type { Locator } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Position } from '@vueuse/core'
import { Locator, expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -1013,8 +1012,6 @@ test.describe('Canvas Navigation', () => {
test('Shift + mouse wheel should pan canvas horizontally', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Canvas.MouseWheelScroll', 'panning')
await comfyPage.page.click('canvas')
await comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

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

View File

@@ -160,9 +160,7 @@ test.describe.skip('Queue sidebar', () => {
comfyPage
}) => {
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
})
test('maintains active gallery item when new tasks are added', async ({
@@ -176,9 +174,7 @@ 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
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
})
test.describe('Gallery navigation', () => {
@@ -200,9 +196,7 @@ test.describe.skip('Queue sidebar', () => {
delay: 256
})
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(end)
).toBeVisible()
expect(comfyPage.menu.queueTab.getGalleryImage(end)).toBeVisible()
})
})
})

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import path from 'path'
import type { Plugin } from 'vite'
import { Plugin } from 'vite'
interface ShimResult {
code: string

View File

@@ -1,7 +1,7 @@
import glob from 'fast-glob'
import fs from 'fs-extra'
import { dirname, join } from 'node:path'
import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite'
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
interface ImportMapSource {
name: string

View File

@@ -5,14 +5,13 @@ import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import storybook from 'eslint-plugin-storybook'
import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue'
import { defineConfig } from 'eslint/config'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import vueParser from 'vue-eslint-parser'
const extraFileExtensions = ['.vue']
export default defineConfig([
export default [
{
files: ['src/**/*.{js,mjs,cjs,ts,vue}']
},
{
ignores: [
'src/scripts/*',
@@ -25,49 +24,35 @@ export default defineConfig([
]
},
{
files: ['./**/*.{ts,mts}'],
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
parser: tseslint.parser,
parserOptions: {
parser: tseslint.parser,
projectService: true,
tsConfigRootDir: import.meta.dirname,
project: ['./tsconfig.json', './tsconfig.eslint.json'],
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions
}
}
},
{
files: ['./**/*.vue'],
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
},
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
projectService: true,
tsConfigRootDir: import.meta.dirname,
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions
extraFileExtensions: ['.vue']
}
}
},
pluginJs.configs.recommended,
tseslint.configs.recommended,
pluginVue.configs['flat/recommended'],
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
storybook.configs['flat/recommended'],
{
files: ['src/**/*.vue'],
languageOptions: {
parserOptions: {
parser: tseslint.parser
}
}
},
{
plugins: {
'unused-imports': unusedImports,
// @ts-expect-error Bad types in the plugin
'@intlify/vue-i18n': pluginI18n
},
rules: {
@@ -75,8 +60,6 @@ export default defineConfig([
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'error',
'unused-imports/no-unused-imports': 'error',
'vue/no-v-html': 'off',
// Enforce dark-theme: instead of dark: prefix
@@ -84,7 +67,6 @@ export default defineConfig([
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
'vue/one-component-per-file': 'off', // TODO: fix
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
// Restrict deprecated PrimeVue components
'no-restricted-imports': [
'error',
@@ -154,13 +136,5 @@ export default defineConfig([
]
}
},
{
files: ['tests-ui/**/*'],
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false }
]
}
}
])
...storybook.configs['flat/recommended']
]

View File

@@ -25,9 +25,7 @@ const config: KnipConfig = {
'src/types/generatedManagerTypes.ts',
'src/types/comfyRegistryTypes.ts',
// 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/scripts/ui/components/splitButton.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -3,13 +3,13 @@ export default {
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'pnpm typecheck'
'vue-tsc --noEmit'
]
}
function formatAndEslint(fileNames) {
return [
`pnpm exec eslint --cache --fix ${fileNames.join(' ')}`,
`pnpm exec prettier --cache --write ${fileNames.join(' ')}`
`eslint --fix ${fileNames.join(' ')}`,
`prettier --write ${fileNames.join(' ')}`
]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.28.0",
"version": "1.27.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -14,9 +14,9 @@
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "vue-tsc --noEmit",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:browser": "npx nx e2e",
"test:unit": "nx run test tests-ui/tests",
@@ -38,10 +38,10 @@
"build-storybook": "storybook build"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@eslint/js": "^9.8.0",
"@iconify-json/lucide": "^1.2.66",
"@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^4.1.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1",
"@nx/eslint": "21.4.1",
"@nx/playwright": "21.4.1",
@@ -64,11 +64,11 @@
"@vitest/ui": "^3.0.0",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-storybook": "^9.1.6",
"eslint-plugin-unused-imports": "^4.2.0",
"eslint-plugin-vue": "^10.4.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-storybook": "^9.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-vue": "^9.27.0",
"fs-extra": "^11.2.0",
"globals": "^15.9.0",
"happy-dom": "^15.11.0",
@@ -79,23 +79,22 @@
"lint-staged": "^15.2.7",
"nx": "21.4.1",
"prettier": "^3.3.2",
"storybook": "^9.1.6",
"storybook": "^9.1.1",
"tailwindcss": "^4.1.12",
"tailwindcss-primeui": "^0.6.1",
"tsx": "^4.15.6",
"tw-animate-css": "^1.3.8",
"typescript": "^5.4.5",
"typescript-eslint": "^8.44.0",
"typescript-eslint": "^8.42.0",
"unplugin-icons": "^0.22.0",
"unplugin-vue-components": "^0.28.0",
"uuid": "^11.1.0",
"vite": "^5.4.19",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^3.2.4",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.7",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
},

1048
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@ 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'
@@ -132,23 +131,6 @@ 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(
@@ -162,8 +144,7 @@ test('collect-i18n-general', async ({ comfyPage }) => {
...allSettingCategoriesLocale
},
serverConfigItems: allServerConfigsLocale,
serverConfigCategories: allServerConfigCategoriesLocale,
desktopDialogs: allDesktopDialogsLocale
serverConfigCategories: allServerConfigCategoriesLocale
},
null,
2

View File

@@ -1,17 +0,0 @@
/* Inter Font Family */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-normal.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-italic.woff2') format('woff2');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}

View File

@@ -1,6 +1,5 @@
@layer theme, base, primevue, components, utilities;
@import './fonts.css';
@import 'tailwindcss/theme' layer(theme);
@import 'tailwindcss/utilities' layer(utilities);
@import 'tw-animate-css';
@@ -53,20 +52,15 @@
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
--color-charcoal-500: #2d2e32;
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-neutral-550: #636363;
--color-charcoal-100: #171718;
--color-charcoal-200: #202121;
--color-charcoal-300: #262729;
--color-charcoal-400: #2d2e32;
--color-charcoal-500: #313235;
--color-charcoal-600: #3c3d42;
--color-charcoal-700: #494a50;
--color-charcoal-800: #55565e;
--color-stone-100: #444444;
--color-stone-200: #828282;
@@ -105,16 +99,12 @@
--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-bypass: #6A246A;
--color-error: #962a2a;
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
--color-blue-selection: rgb( from var(--color-blue-100) r g b / 0.3);
--color-node-hover-100: rgb( from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
/* PrimeVue pulled colors */
@@ -127,10 +117,10 @@
}
@theme inline {
--color-node-component-surface: var(--color-charcoal-600);
--color-node-component-surface: var(--color-charcoal-300);
--color-node-component-surface-highlight: var(--color-slate-100);
--color-node-component-surface-hovered: var(--color-charcoal-400);
--color-node-component-surface-selected: var(--color-charcoal-200);
--color-node-component-surface-hovered: var(--color-charcoal-500);
--color-node-component-surface-selected: var(--color-charcoal-700);
--color-node-stroke: var(--color-stone-100);
}
@@ -142,7 +132,7 @@
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {

View File

@@ -21,8 +21,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import type { CSSProperties } from 'vue'
import { computed, watchEffect } from 'vue'
import { CSSProperties, computed, watchEffect } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'

View File

@@ -0,0 +1,546 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BatchCountEdit from './BatchCountEdit.vue'
const meta: Meta = {
title: 'Components/Actionbar/BatchCountEdit',
component: BatchCountEdit,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'BatchCountEdit allows users to set the batch count for queue operations with smart increment/decrement logic. Features exponential scaling (doubling/halving) and integrates with the queue settings store for ComfyUI workflow execution. This component can accept props for controlled mode or use Pinia store state by default.'
}
}
},
argTypes: {
minQueueCount: {
control: 'number',
description: 'Minimum allowed batch count',
table: {
defaultValue: { summary: '1' }
}
},
maxQueueCount: {
control: 'number',
description: 'Maximum allowed batch count',
table: {
defaultValue: { summary: '100' }
}
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const Default: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Batch Count Editor</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Set the number of times to run the workflow. Smart increment/decrement with exponential scaling.
</p>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px;">
<span style="font-weight: 600;">Batch Count:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 12px; color: #6b7280; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<strong>Note:</strong> Current value: {{count}}. Check console for action logs.
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Default batch count editor with smart exponential scaling. Uses Pinia store for state management. Click +/- buttons to see the doubling/halving behavior.'
}
}
}
}
export const WithTooltip: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 50
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 4,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 40px;">
<div style="margin-bottom: 16px; text-align: center;">
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
Hover over the input to see tooltip
</div>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 12px; color: #6b7280; text-align: center; margin-top: 20px;">
⬆️ Tooltip appears on hover with 600ms delay
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit with tooltip functionality - hover to see the "Batch Count" tooltip.'
}
}
}
}
export const HighBatchCount: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 200
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 16,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<div style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
High batch count scenario (16 generations):
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-weight: 600;">Batch Count:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
</div>
<div style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); border-radius: 4px; padding: 12px;">
<div style="display: flex; align-items: center; gap: 8px; color: #b45309;">
<i class="pi pi-exclamation-triangle"></i>
<span style="font-size: 14px; font-weight: 600;">High Batch Count Warning</span>
</div>
<div style="font-size: 12px; color: #92400e; margin-top: 4px;">
Running 16 generations will consume significant GPU time and memory. Consider reducing batch size for faster iteration.
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'High batch count scenario showing potential performance warnings for large generation batches.'
}
}
}
}
export const ActionBarContext: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 2,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
BatchCountEdit in realistic action bar context:
</div>
<div style="display: flex; align-items: center; gap: 16px; padding: 12px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
<!-- Mock Queue Button -->
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
<i class="pi pi-play"></i>
Queue Prompt
</button>
<!-- BatchCountEdit -->
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<!-- Mock Clear Button -->
<button style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #6b7280; color: white; border: none; border-radius: 6px; cursor: pointer;">
<i class="pi pi-trash"></i>
Clear
</button>
<!-- Mock Settings -->
<button style="padding: 8px; background: none; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer;">
<i class="pi pi-cog" style="color: #6b7280;"></i>
</button>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit integrated within a realistic ComfyUI action bar layout with queue controls.'
}
}
}
}
export const ExponentialScaling: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 100
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
scalingLog: [],
currentValue: 1,
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
methods: {
simulateIncrement() {
const current = this.currentValue
const newValue = Math.min(current * 2, 100)
this.scalingLog.unshift(`Increment: ${current}${newValue} (×2)`)
this.currentValue = newValue
if (this.scalingLog.length > 10) this.scalingLog.pop()
},
simulateDecrement() {
const current = this.currentValue
const newValue = Math.floor(current / 2) || 1
this.scalingLog.unshift(`Decrement: ${current}${newValue} (÷2)`)
this.currentValue = newValue
if (this.scalingLog.length > 10) this.scalingLog.pop()
},
reset() {
this.currentValue = 1
this.scalingLog = []
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Exponential Scaling Demo</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Demonstrates the smart doubling/halving behavior of batch count controls.
</p>
</div>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-weight: 600;">Current Value:</span>
<span style="font-size: 18px; font-weight: bold; color: #3b82f6;">{{ currentValue }}</span>
</div>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<button @click="simulateIncrement" style="padding: 6px 12px; background: #10b981; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-plus"></i> Double
</button>
<button @click="simulateDecrement" style="padding: 6px 12px; background: #ef4444; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-minus"></i> Halve
</button>
<button @click="reset" style="padding: 6px 12px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer;">
<i class="pi pi-refresh"></i> Reset
</button>
</div>
<div v-if="scalingLog.length" style="background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Scaling Log:</div>
<div v-for="(entry, index) in scalingLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
{{ entry }}
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Demonstrates the exponential scaling behavior - increment doubles the value, decrement halves it.'
}
}
}
}
export const QueueWorkflowContext: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 50
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
queueStatus: 'Ready',
totalGenerations: 1,
estimatedTime: '~2 min',
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
computed: {
statusColor() {
return this.queueStatus === 'Ready'
? '#10b981'
: this.queueStatus === 'Running'
? '#f59e0b'
: '#6b7280'
}
},
methods: {
updateEstimate() {
// Simulate batch count change affecting estimates
this.totalGenerations = 1 // This would be updated by actual batch count
this.estimatedTime = `~${this.totalGenerations * 2} min`
},
queueWorkflow() {
this.queueStatus = 'Running'
setTimeout(() => {
this.queueStatus = 'Complete'
}, 3000)
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Queue Workflow Context</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
BatchCountEdit within a complete workflow queuing interface.
</p>
</div>
<!-- Mock Workflow Preview -->
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<i class="pi pi-sitemap" style="color: #6366f1;"></i>
<span style="font-weight: 600;">SDXL Portrait Generation</span>
<span :style="{color: statusColor, fontSize: '12px', fontWeight: '600'}" style="background: rgba(0,0,0,0.05); padding: 2px 8px; border-radius: 12px;">
{{ queueStatus }}
</span>
</div>
<!-- Queue Controls -->
<div style="display: flex; align-items: center; gap: 12px; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 12px;">
<button @click="queueWorkflow" style="display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">
<i class="pi pi-play"></i>
Queue Prompt
</button>
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-size: 12px; color: #6b7280; font-weight: 600;">BATCH:</label>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
</div>
<div style="text-align: right;">
<div style="font-size: 12px; color: #6b7280;">Total: {{ totalGenerations }} generations</div>
<div style="font-size: 12px; color: #6b7280;">Est. time: {{ estimatedTime }}</div>
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'BatchCountEdit in a complete workflow queuing context with status and time estimates.'
}
}
}
}
export const LimitConstraints: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 200
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 1,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
},
scenarios: [
{
name: 'Conservative (max 10)',
maxLimit: 10,
description: 'For memory-constrained systems'
},
{
name: 'Standard (max 50)',
maxLimit: 50,
description: 'Typical production usage'
},
{
name: 'High-end (max 200)',
maxLimit: 200,
description: 'For powerful GPU setups'
}
]
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Limit Constraints</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Different batch count limits for various system configurations.
</p>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px;">
<div v-for="scenario in scenarios" :key="scenario.name" style="border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
<div style="font-weight: 600; margin-bottom: 4px;">{{ scenario.name }}</div>
<div style="font-size: 12px; color: #6b7280; margin-bottom: 12px;">{{ scenario.description }}</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 12px; font-weight: 600;">BATCH:</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
</div>
<div style="font-size: 11px; color: #9ca3af; margin-top: 8px;">
Max limit: {{ scenario.maxLimit }}
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Different batch count limit scenarios for various system configurations and use cases.'
}
}
}
}
export const MinimalInline: Story = {
args: {
minQueueCount: 1,
maxQueueCount: 20
},
render: (_args) => ({
components: { BatchCountEdit },
data() {
return {
count: 3,
logAction: (action: string, value: number) => {
console.log(`${action}: ${value}`)
}
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Minimal inline usage:
</div>
<div style="display: flex; align-items: center; gap: 8px; font-size: 14px;">
<span>Run</span>
<BatchCountEdit
v-model:batch-count="count"
:min-queue-count="+_args.minQueueCount"
:max-queue-count="+_args.maxQueueCount"
@update:batch-count="(v) => logAction('Set', Number(v))"
/>
<span>times</span>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Minimal inline usage of BatchCountEdit within a sentence context.'
}
}
}
}

View File

@@ -40,15 +40,51 @@ import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
interface Props {
batchCount?: number
minQueueCount?: number
maxQueueCount?: number
}
interface Emits {
(e: 'update:batch-count', value: number): void
}
const props = withDefaults(defineProps<Props>(), {
batchCount: undefined,
minQueueCount: 1,
maxQueueCount: undefined
})
const emit = defineEmits<Emits>()
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1
const { batchCount: storeBatchCount } = storeToRefs(queueSettingsStore)
const settingStore = useSettingStore()
const maxQueueCount = computed(() =>
const defaultMaxQueueCount = computed(() =>
settingStore.get('Comfy.QueueButton.BatchCountLimit')
)
// Use props if provided, otherwise fallback to store values
const batchCount = computed({
get() {
return props.batchCount ?? storeBatchCount.value
},
set(value: number) {
if (props.batchCount !== undefined) {
emit('update:batch-count', value)
} else {
storeBatchCount.value = value
}
}
})
const minQueueCount = computed(() => props.minQueueCount)
const maxQueueCount = computed(
() => props.maxQueueCount ?? defaultMaxQueueCount.value
)
const handleClick = (increment: boolean) => {
let newCount: number
if (increment) {

View File

@@ -22,8 +22,7 @@ import {
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import Panel from 'primevue/panel'
import type { Ref } from 'vue'
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'

View File

@@ -26,8 +26,7 @@
import { useElementHover, useEventListener } from '@vueuse/core'
import type { IDisposable } from '@xterm/xterm'
import Button from 'primevue/button'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Ref, computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
@@ -47,7 +46,7 @@ const hasSelection = ref(false)
const isHovered = useElementHover(rootEl)
const terminalData = useTerminal(terminalEl)
emit('created', terminalData, ref(rootEl))
emit('created', terminalData, rootEl)
const { terminal } = terminalData
let selectionDisposable: IDisposable | undefined

View File

@@ -3,9 +3,8 @@
</template>
<script setup lang="ts">
import type { IDisposable } from '@xterm/xterm'
import type { Ref } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { IDisposable } from '@xterm/xterm'
import { Ref, onMounted, onUnmounted } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI } from '@/utils/envUtil'

View File

@@ -15,11 +15,10 @@
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { Ref, onMounted, onUnmounted, ref } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import type { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
import { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'

View File

@@ -47,8 +47,7 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import type { MenuState } from 'primevue/menu'
import Menu from 'primevue/menu'
import Menu, { MenuState } from 'primevue/menu'
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import { computed, nextTick, ref } from 'vue'

View File

@@ -0,0 +1,152 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ContentDivider from './ContentDivider.vue'
const meta: Meta<typeof ContentDivider> = {
title: 'Components/Common/ContentDivider',
component: ContentDivider,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'ContentDivider provides a visual separation between content sections. It supports both horizontal and vertical orientations with customizable width/thickness.'
}
}
},
argTypes: {
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Direction of the divider line',
defaultValue: 'horizontal'
},
width: {
control: { type: 'range', min: 0.1, max: 10, step: 0.1 },
description: 'Width/thickness of the divider in pixels',
defaultValue: 0.3
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof ContentDivider>
export const Horizontal: Story = {
args: {
orientation: 'horizontal',
width: 0.3
},
parameters: {
docs: {
description: {
story:
'Default horizontal divider for separating content sections vertically.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
Content Section 1
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
Content Section 2
</div>
</div>
`
})
]
}
export const Vertical: Story = {
args: {
orientation: 'vertical',
width: 0.3
},
parameters: {
docs: {
description: {
story: 'Vertical divider for separating content sections horizontally.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 10px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 10px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}
export const ThickHorizontal: Story = {
args: {
orientation: 'horizontal',
width: 2
},
parameters: {
docs: {
description: {
story:
'Thicker horizontal divider for more prominent visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-bottom: 10px;">
Content Section 1
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-top: 10px;">
Content Section 2
</div>
</div>
`
})
]
}
export const ThickVertical: Story = {
args: {
orientation: 'vertical',
width: 3
},
parameters: {
docs: {
description: {
story: 'Thicker vertical divider for more prominent visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 10px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="props.device" class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-2">
<template v-for="col in deviceColumns" :key="col.field">
<div class="font-medium">
{{ col.header }}
@@ -9,9 +9,6 @@
</div>
</template>
</div>
<div v-else class="text-red-500">
{{ $t('g.deviceNotAvailable') }}
</div>
</template>
<script setup lang="ts">
@@ -19,7 +16,7 @@ import type { DeviceStats } from '@/schemas/apiSchema'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
device: DeviceStats | undefined
device: DeviceStats
}>()
const deviceColumns: { field: keyof DeviceStats; header: string }[] = [
@@ -32,10 +29,6 @@ const deviceColumns: { field: keyof DeviceStats; header: string }[] = [
]
const formatValue = (value: any, field: string) => {
if (value === undefined || value === null) {
return 'N/A'
}
if (
['vram_total', 'vram_free', 'torch_vram_total', 'torch_vram_free'].includes(
field

View File

@@ -0,0 +1,259 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import EditableText from './EditableText.vue'
const meta: Meta<typeof EditableText> = {
title: 'Components/Common/EditableText',
component: EditableText,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'EditableText allows inline text editing with sophisticated focus management and keyboard handling. It supports automatic text selection, smart filename handling (excluding extensions), and seamless transitions between view and edit modes.'
}
}
},
argTypes: {
modelValue: {
control: 'text',
description: 'The text value to display and edit'
},
isEditing: {
control: 'boolean',
description: 'Whether the component is currently in edit mode'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof EditableText>
const createEditableStoryRender =
(
initialText = 'Click to edit this text',
initialEditing = false,
stayEditing = false
) =>
(args: any) => ({
components: { EditableText },
setup() {
const text = ref(args.modelValue || initialText)
const editing = ref(args.isEditing ?? initialEditing)
const actions = ref<string[]>([])
const logAction = (action: string, data?: any) => {
const timestamp = new Date().toLocaleTimeString()
const message = data
? `${action}: "${data}" (${timestamp})`
: `${action} (${timestamp})`
actions.value.unshift(message)
if (actions.value.length > 5) actions.value.pop()
console.log(action, data)
}
const handleEdit = (newValue: string) => {
logAction('Edit completed', newValue)
text.value = newValue
editing.value = stayEditing // Stay in edit mode if specified
}
const startEdit = () => {
editing.value = true
logAction('Edit started')
}
return { args, text, editing, actions, handleEdit, startEdit }
},
template: `
<div style="padding: 20px;">
<div @click="startEdit" style="cursor: pointer; border: 2px dashed #ccc; border-radius: 4px; padding: 20px;">
<div style="margin-bottom: 8px; font-size: 12px; color: #666;">Click text to edit:</div>
<EditableText
:modelValue="text"
:isEditing="editing"
@edit="handleEdit"
/>
</div>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createEditableStoryRender(),
args: {
modelValue: 'Click to edit this text',
isEditing: false
}
}
export const AlwaysEditing: Story = {
render: createEditableStoryRender('Always in edit mode', true, true),
args: {
modelValue: 'Always in edit mode',
isEditing: true
}
}
export const FilenameEditing: Story = {
render: () => ({
components: { EditableText },
setup() {
const filenames = ref([
'my_workflow.json',
'image_processing.png',
'model_config.yaml',
'final_render.mp4'
])
const actions = ref<string[]>([])
const logAction = (action: string, filename: string, newName: string) => {
const timestamp = new Date().toLocaleTimeString()
actions.value.unshift(
`${action}: "${filename}" → "${newName}" (${timestamp})`
)
if (actions.value.length > 5) actions.value.pop()
console.log(action, { filename, newName })
}
const handleFilenameEdit = (index: number, newValue: string) => {
const oldName = filenames.value[index]
filenames.value[index] = newValue
logAction('Filename changed', oldName, newValue)
}
return { filenames, actions, handleFilenameEdit }
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-weight: bold;">File Browser (click filenames to edit):</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(filename, index) in filenames" :key="index"
style="display: flex; align-items: center; padding: 8px; background: #f9f9f9; border-radius: 4px;">
<i style="margin-right: 8px; color: #666;" class="pi pi-file"></i>
<EditableText
:modelValue="filename"
:isEditing="false"
@edit="(newValue) => handleFilenameEdit(index, newValue)"
/>
</div>
</div>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
}
export const LongText: Story = {
render: createEditableStoryRender(
'This is a much longer text that demonstrates how the EditableText component handles longer content with multiple words and potentially line wrapping scenarios.'
),
args: {
modelValue:
'This is a much longer text that demonstrates how the EditableText component handles longer content.',
isEditing: false
}
}
export const EmptyState: Story = {
render: createEditableStoryRender(''),
args: {
modelValue: '',
isEditing: false
}
}
export const SingleCharacter: Story = {
render: createEditableStoryRender('A'),
args: {
modelValue: 'A',
isEditing: false
}
}
// ComfyUI usage examples
export const WorkflowNaming: Story = {
render: () => ({
components: { EditableText },
setup() {
const workflows = ref([
'Portrait Enhancement',
'Landscape Generation',
'Style Transfer Workflow',
'Untitled Workflow'
])
const handleWorkflowRename = (index: number, newName: string) => {
workflows.value[index] = newName
console.log('Workflow renamed:', { index, newName })
}
return { workflows, handleWorkflowRename }
},
template: `
<div style="padding: 20px; width: 300px;">
<div style="margin-bottom: 16px; font-weight: bold;">Workflow Library</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
<div v-for="(workflow, index) in workflows" :key="index"
style="padding: 12px; border: 1px solid #ddd; border-radius: 6px; background: white;">
<EditableText
:modelValue="workflow"
:isEditing="false"
@edit="(newName) => handleWorkflowRename(index, newName)"
style="font-size: 14px; font-weight: 500;"
/>
<div style="margin-top: 4px; font-size: 11px; color: #666;">
Last modified: 2 hours ago
</div>
</div>
</div>
</div>
`
})
}
export const ModelRenaming: Story = {
render: () => ({
components: { EditableText },
setup() {
const models = ref([
'stable-diffusion-v1-5.safetensors',
'controlnet_depth.pth',
'vae-ft-mse-840000-ema.ckpt'
])
const handleModelRename = (index: number, newName: string) => {
models.value[index] = newName
console.log('Model renamed:', { index, newName })
}
return { models, handleModelRename }
},
template: `
<div style="padding: 20px; width: 350px;">
<div style="margin-bottom: 16px; font-weight: bold;">Model Manager</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div v-for="(model, index) in models" :key="index"
style="display: flex; align-items: center; padding: 8px; background: #f8f8f8; border-radius: 4px;">
<i style="margin-right: 8px; color: #4a90e2;" class="pi pi-box"></i>
<EditableText
:modelValue="model"
:isEditing="false"
@edit="(newName) => handleModelRename(index, newName)"
style="flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 12px;"
/>
</div>
</div>
</div>
`
})
}

View File

@@ -7,7 +7,7 @@
<InputText
v-else
ref="inputRef"
v-model:model-value="inputValue"
v-model:modelValue="inputValue"
v-focus
type="text"
size="small"

View File

@@ -17,7 +17,7 @@
<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import type { CustomExtension, VueExtension } from '@/types/extensionTypes'
import { CustomExtension, VueExtension } from '@/types/extensionTypes'
const props = defineProps<{
extension: VueExtension | CustomExtension

View File

@@ -0,0 +1,672 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { FormItem as FormItemType } from '@/types/settingTypes'
import FormItem from './FormItem.vue'
const meta: Meta = {
title: 'Components/Common/FormItem',
component: FormItem as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'FormItem is a generalized form component that dynamically renders different input types based on configuration. Supports text, number, boolean, combo, slider, knob, color, image, and custom renderer inputs with proper labeling and accessibility.'
}
}
},
argTypes: {
item: {
control: 'object',
description:
'FormItem configuration object defining the input type and properties'
},
formValue: {
control: 'text',
description: 'The current form value (v-model)',
defaultValue: ''
},
id: {
control: 'text',
description: 'Optional HTML id for the form input',
defaultValue: undefined
},
labelClass: {
control: 'text',
description: 'Additional CSS classes for the label',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const TextInput: Story = {
render: (args: any) => ({
components: { FormItem },
setup() {
return { args }
},
data() {
return {
value: args.formValue || 'Default text value',
textItem: {
name: 'Workflow Name',
type: 'text',
tooltip: 'Enter a descriptive name for your workflow',
attrs: {
placeholder: 'e.g., SDXL Portrait Generation'
}
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Text value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Text input form item with tooltip:
</div>
<FormItem
:item="textItem"
:formValue="value"
@update:formValue="updateValue"
id="workflow-name"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Current value: "{{ value }}"
</div>
</div>
`
}),
args: {
formValue: 'My Workflow'
},
parameters: {
docs: {
description: {
story:
'Text input FormItem with tooltip and placeholder. Hover over the info icon to see the tooltip.'
}
}
}
}
export const NumberInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 7.5,
numberItem: {
name: 'CFG Scale',
type: 'number',
tooltip:
'Classifier-free guidance scale controls how closely the AI follows your prompt',
attrs: {
min: 1,
max: 30,
step: 0.5,
showButtons: true
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('CFG scale updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Number input with controls and constraints:
</div>
<FormItem
:item="numberItem"
:formValue="value"
@update:formValue="updateValue"
id="cfg-scale"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Current CFG scale: {{ value }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Number input FormItem with min/max constraints and increment buttons for CFG scale parameter.'
}
}
}
}
export const BooleanToggle: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: false,
booleanItem: {
name: 'Enable GPU Acceleration',
type: 'boolean',
tooltip: 'Use GPU for faster processing when available'
} as FormItemType
}
},
methods: {
updateValue(newValue: boolean) {
console.log('GPU acceleration toggled:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Boolean toggle switch form item:
</div>
<FormItem
:item="booleanItem"
:formValue="value"
@update:formValue="updateValue"
id="gpu-accel"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
GPU acceleration: {{ value ? 'Enabled' : 'Disabled' }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Boolean FormItem using ToggleSwitch component for enable/disable settings.'
}
}
}
}
export const ComboSelect: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'euler_a',
comboItem: {
name: 'Sampling Method',
type: 'combo',
tooltip: 'Algorithm used for denoising during generation',
options: [
'euler_a',
'euler',
'heun',
'dpm_2',
'dpm_2_ancestral',
'lms',
'dpm_fast',
'dpm_adaptive',
'dpmpp_2s_ancestral',
'dpmpp_sde',
'dpmpp_2m'
]
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Sampling method updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Combo select with sampling methods:
</div>
<FormItem
:item="comboItem"
:formValue="value"
@update:formValue="updateValue"
id="sampling-method"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Selected: {{ value }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Combo select FormItem with ComfyUI sampling methods showing dropdown selection.'
}
}
}
}
export const SliderInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 0.7,
sliderItem: {
name: 'Denoise Strength',
type: 'slider',
tooltip:
'How much to denoise the input image (0 = no change, 1 = complete redraw)',
attrs: {
min: 0,
max: 1,
step: 0.01
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('Denoise strength updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Slider input with precise decimal control:
</div>
<FormItem
:item="sliderItem"
:formValue="value"
@update:formValue="updateValue"
id="denoise-strength"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Denoise: {{ (value * 100).toFixed(0) }}%
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Slider FormItem for denoise strength with percentage display and fine-grained control.'
}
}
}
}
export const KnobInput: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 20,
knobItem: {
name: 'Sampling Steps',
type: 'knob',
tooltip:
'Number of denoising steps - more steps = higher quality but slower generation',
attrs: {
min: 1,
max: 150,
step: 1
}
} as FormItemType
}
},
methods: {
updateValue(newValue: number) {
console.log('Steps updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Knob input for sampling steps:
</div>
<FormItem
:item="knobItem"
:formValue="value"
@update:formValue="updateValue"
id="sampling-steps"
/>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Steps: {{ value }} ({{ value < 10 ? 'Very Fast' : value < 30 ? 'Fast' : value < 50 ? 'Balanced' : 'High Quality' }})
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Knob FormItem for sampling steps with quality indicator based on step count.'
}
}
}
}
export const MultipleFormItems: Story = {
render: () => ({
components: { FormItem },
data() {
return {
widthValue: 512,
heightValue: 512,
stepsValue: 20,
cfgValue: 7.5,
samplerValue: 'euler_a',
hiresValue: false
}
},
computed: {
formItems() {
return [
{
name: 'Width',
type: 'number',
tooltip: 'Image width in pixels',
attrs: { min: 64, max: 2048, step: 64 }
},
{
name: 'Height',
type: 'number',
tooltip: 'Image height in pixels',
attrs: { min: 64, max: 2048, step: 64 }
},
{
name: 'Sampling Steps',
type: 'knob',
tooltip: 'Number of denoising steps',
attrs: { min: 1, max: 150, step: 1 }
},
{
name: 'CFG Scale',
type: 'slider',
tooltip: 'Classifier-free guidance scale',
attrs: { min: 1, max: 30, step: 0.5 }
},
{
name: 'Sampler',
type: 'combo',
tooltip: 'Sampling algorithm',
options: ['euler_a', 'euler', 'heun', 'dpm_2', 'dpmpp_2m']
},
{
name: 'High-res Fix',
type: 'boolean',
tooltip: 'Enable high-resolution generation'
}
] as FormItemType[]
},
allSettings() {
return {
width: this.widthValue,
height: this.heightValue,
steps: this.stepsValue,
cfg: this.cfgValue,
sampler: this.samplerValue,
enableHires: this.hiresValue
}
}
},
template: `
<div style="padding: 20px; min-width: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI Generation Settings</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Multiple form items demonstrating different input types in a realistic settings panel.
</p>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;">
<div style="display: flex; flex-direction: column; gap: 16px;">
<FormItem
:item="formItems[0]"
:formValue="widthValue"
@update:formValue="(value) => widthValue = value"
id="form-width"
/>
<FormItem
:item="formItems[1]"
:formValue="heightValue"
@update:formValue="(value) => heightValue = value"
id="form-height"
/>
<FormItem
:item="formItems[2]"
:formValue="stepsValue"
@update:formValue="(value) => stepsValue = value"
id="form-steps"
/>
<FormItem
:item="formItems[3]"
:formValue="cfgValue"
@update:formValue="(value) => cfgValue = value"
id="form-cfg"
/>
<FormItem
:item="formItems[4]"
:formValue="samplerValue"
@update:formValue="(value) => samplerValue = value"
id="form-sampler"
/>
<FormItem
:item="formItems[5]"
:formValue="hiresValue"
@update:formValue="(value) => hiresValue = value"
id="form-hires"
/>
</div>
</div>
<div style="margin-top: 16px; background: rgba(0,0,0,0.05); padding: 12px; border-radius: 4px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Current Settings:</div>
<div style="font-family: monospace; font-size: 12px; color: #4b5563;">
{{ JSON.stringify(allSettings, null, 2) }}
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Multiple FormItems demonstrating all major input types in a realistic ComfyUI settings panel.'
}
}
}
}
export const WithCustomLabels: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'custom_model.safetensors',
customItem: {
name: 'Model File',
type: 'text',
tooltip: 'Select the checkpoint model file to use for generation',
attrs: {
placeholder: 'Select or enter model filename...'
}
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Model file updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
FormItem with custom label styling and slots:
</div>
<FormItem
:item="customItem"
:formValue="value"
@update:formValue="updateValue"
id="model-file"
:labelClass="{ 'font-bold': true, 'text-blue-600': true }"
>
<template #name-prefix>
<i class="pi pi-download" style="margin-right: 6px; color: #3b82f6;"></i>
</template>
<template #name-suffix>
<span style="margin-left: 6px; font-size: 10px; color: #ef4444;">*</span>
</template>
</FormItem>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Selected model: {{ value || 'None' }}
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'FormItem with custom label styling and prefix/suffix slots for enhanced UI elements.'
}
}
}
}
export const ColorPicker: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: '#3b82f6',
colorItem: {
name: 'Theme Accent Color',
type: 'color',
tooltip: 'Primary accent color for the interface theme'
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Color updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Color picker form item:
</div>
<FormItem
:item="colorItem"
:formValue="value"
@update:formValue="updateValue"
id="theme-color"
/>
<div style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
<div style="font-size: 12px; color: #6b7280;">Preview:</div>
<div
:style="{
backgroundColor: value,
width: '40px',
height: '20px',
borderRadius: '4px',
border: '1px solid #e2e8f0'
}"
></div>
<span style="font-family: monospace; font-size: 12px;">{{ value }}</span>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Color picker FormItem with live preview showing the selected color value.'
}
}
}
}
export const ComboWithComplexOptions: Story = {
render: () => ({
components: { FormItem },
data() {
return {
value: 'medium',
comboItem: {
name: 'Quality Preset',
type: 'combo',
tooltip:
'Predefined quality settings that adjust multiple parameters',
options: [
{ text: 'Draft (Fast)', value: 'draft' },
{ text: 'Medium Quality', value: 'medium' },
{ text: 'High Quality', value: 'high' },
{ text: 'Ultra (Slow)', value: 'ultra' }
]
} as FormItemType
}
},
methods: {
updateValue(newValue: string) {
console.log('Quality preset updated:', newValue)
this.value = newValue
}
},
computed: {
presetDescription() {
const descriptions = {
draft: 'Fast generation with 10 steps, suitable for previews',
medium: 'Balanced quality with 20 steps, good for most use cases',
high: 'High quality with 40 steps, slower but better results',
ultra: 'Maximum quality with 80 steps, very slow but best results'
}
return (descriptions as any)[this.value] || 'Unknown preset'
}
},
template: `
<div style="padding: 20px; min-width: 400px;">
<div style="margin-bottom: 16px; font-size: 14px; color: #6b7280;">
Combo with complex option objects:
</div>
<FormItem
:item="comboItem"
:formValue="value"
@update:formValue="updateValue"
id="quality-preset"
/>
<div style="margin-top: 12px; padding: 8px; background: rgba(0,0,0,0.05); border-radius: 4px;">
<div style="font-size: 12px; font-weight: 600; color: #374151;">{{ presetDescription }}</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Complex combo FormItem with object options showing text/value pairs and descriptions.'
}
}
}
}

View File

@@ -21,7 +21,7 @@
<component
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
v-model:model-value="formValue"
v-model:modelValue="formValue"
:aria-labelledby="`${props.id}-label`"
v-bind="getFormAttrs(props.item)"
/>
@@ -44,7 +44,7 @@ import FormRadioGroup from '@/components/common/FormRadioGroup.vue'
import InputKnob from '@/components/common/InputKnob.vue'
import InputSlider from '@/components/common/InputSlider.vue'
import UrlInput from '@/components/common/UrlInput.vue'
import type { FormItem } from '@/platform/settings/types'
import { FormItem } from '@/platform/settings/types'
const formValue = defineModel<any>('formValue')
const props = defineProps<{

View File

@@ -0,0 +1,566 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import InputKnob from './InputKnob.vue'
const meta: Meta<typeof InputKnob> = {
title: 'Components/Common/InputKnob',
component: InputKnob,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'InputKnob combines a PrimeVue Knob and InputNumber for dual input methods. It features value synchronization, range validation, step constraints, and automatic decimal precision handling based on step values.'
}
}
},
argTypes: {
modelValue: {
control: { type: 'number' },
description: 'Current numeric value (v-model)',
defaultValue: 50
},
min: {
control: { type: 'number' },
description: 'Minimum allowed value',
defaultValue: 0
},
max: {
control: { type: 'number' },
description: 'Maximum allowed value',
defaultValue: 100
},
step: {
control: { type: 'number', step: 0.01 },
description: 'Step increment for both knob and input',
defaultValue: 1
},
resolution: {
control: { type: 'number', min: 0, max: 5 },
description:
'Number of decimal places to display (auto-calculated from step if not provided)',
defaultValue: undefined
},
inputClass: {
control: 'text',
description: 'Additional CSS classes for the number input',
defaultValue: undefined
},
knobClass: {
control: 'text',
description: 'Additional CSS classes for the knob',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof InputKnob>
export const Default: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 50
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Current Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
:inputClass="args.inputClass"
:knobClass="args.knobClass"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 50,
min: 0,
max: 100,
step: 1
},
parameters: {
docs: {
description: {
story:
'Default InputKnob with range 0-100 and step of 1. Use either the knob or number input to change the value.'
}
}
}
}
export const DecimalPrecision: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 2.5
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Decimal value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Precision Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 2.5,
min: 0,
max: 10,
step: 0.1
},
parameters: {
docs: {
description: {
story:
'InputKnob with decimal step (0.1) - automatically shows one decimal place based on step precision.'
}
}
}
}
export const HighPrecision: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 1.234
}
},
methods: {
handleUpdate(newValue: number) {
console.log('High precision value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>High Precision: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
:resolution="args.resolution"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 1.234,
min: 0,
max: 5,
step: 0.001,
resolution: 3
},
parameters: {
docs: {
description: {
story:
'High precision InputKnob with step of 0.001 and 3 decimal places resolution.'
}
}
}
}
export const LargeRange: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 500
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Large range value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Large Range Value: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 500,
min: 0,
max: 1000,
step: 10
},
parameters: {
docs: {
description: {
story:
'InputKnob with large range (0-1000) and step of 10 for coarser control.'
}
}
}
}
export const NegativeRange: Story = {
render: (args) => ({
components: { InputKnob },
setup() {
return { args }
},
data() {
return {
value: args.modelValue || 0
}
},
methods: {
handleUpdate(newValue: number) {
console.log('Negative range value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px;">
<strong>Negative Range: {{ value }}</strong>
</div>
<InputKnob
:modelValue="value"
:min="args.min"
:max="args.max"
:step="args.step"
@update:modelValue="handleUpdate"
/>
</div>
`
}),
args: {
modelValue: 0,
min: -50,
max: 50,
step: 5
},
parameters: {
docs: {
description: {
story:
'InputKnob with negative range (-50 to 50) demonstrating bidirectional control.'
}
}
}
}
// ComfyUI specific examples
export const CFGScale: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
cfgScale: 7.5
}
},
methods: {
updateCFG(value: number) {
console.log('CFG Scale updated:', value)
this.cfgScale = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
CFG Scale
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
Controls how closely the model follows the prompt
</div>
<InputKnob
:modelValue="cfgScale"
:min="1"
:max="20"
:step="0.5"
@update:modelValue="updateCFG"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ cfgScale }} (Recommended: 6-8)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI CFG Scale parameter example - common parameter for controlling prompt adherence.'
}
}
}
}
export const SamplingSteps: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
steps: 20
}
},
methods: {
updateSteps(value: number) {
console.log('Sampling steps updated:', value)
this.steps = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
Sampling Steps
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
Number of denoising steps for image generation
</div>
<InputKnob
:modelValue="steps"
:min="1"
:max="150"
:step="1"
@update:modelValue="updateSteps"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ steps }} (Higher = better quality, slower)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI Sampling Steps parameter example - controls generation quality vs speed.'
}
}
}
}
export const DenoiseStrength: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
denoise: 1.0
}
},
methods: {
updateDenoise(value: number) {
console.log('Denoise strength updated:', value)
this.denoise = value
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; color: #374151;">
Denoise Strength
</div>
<div style="margin-bottom: 8px; font-size: 14px; color: #6b7280;">
How much noise to add (1.0 = complete denoising)
</div>
<InputKnob
:modelValue="denoise"
:min="0"
:max="1"
:step="0.01"
@update:modelValue="updateDenoise"
/>
<div style="margin-top: 8px; font-size: 12px; color: #9ca3af;">
Current: {{ denoise }} (0.0 = no change, 1.0 = full generation)
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'ComfyUI Denoise Strength parameter example - high precision control for img2img workflows.'
}
}
}
}
export const CustomStyling: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
value: 75
}
},
methods: {
updateValue(newValue: number) {
console.log('Custom styled value updated:', newValue)
this.value = newValue
}
},
template: `
<div style="padding: 20px;">
<div style="margin-bottom: 16px; font-weight: 600;">
Custom Styled InputKnob
</div>
<InputKnob
:modelValue="value"
:min="0"
:max="100"
:step="1"
inputClass="custom-input"
knobClass="custom-knob"
@update:modelValue="updateValue"
/>
<style>
.custom-input {
font-weight: bold;
color: #2563eb;
}
.custom-knob {
transform: scale(1.2);
}
</style>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'InputKnob with custom CSS classes applied to both knob and input components.'
}
}
}
}
// Gallery showing different parameter types
export const ParameterGallery: Story = {
render: () => ({
components: { InputKnob },
data() {
return {
params: {
cfg: 7.5,
steps: 20,
denoise: 1.0,
temperature: 0.8
}
}
},
methods: {
updateParam(param: string, value: number) {
console.log(`${param} updated:`, value)
;(this.params as any)[param] = value
}
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; padding: 20px; max-width: 600px;">
<div>
<div style="font-weight: 600; margin-bottom: 8px;">CFG Scale</div>
<InputKnob
:modelValue="params.cfg"
:min="1"
:max="20"
:step="0.5"
@update:modelValue="(v) => updateParam('cfg', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Steps</div>
<InputKnob
:modelValue="params.steps"
:min="1"
:max="100"
:step="1"
@update:modelValue="(v) => updateParam('steps', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Denoise</div>
<InputKnob
:modelValue="params.denoise"
:min="0"
:max="1"
:step="0.01"
@update:modelValue="(v) => updateParam('denoise', v)"
/>
</div>
<div>
<div style="font-weight: 600; margin-bottom: 8px;">Temperature</div>
<InputKnob
:modelValue="params.temperature"
:min="0"
:max="2"
:step="0.1"
@update:modelValue="(v) => updateParam('temperature', v)"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Gallery showing different parameter types commonly used in ComfyUI workflows.'
}
}
}
}

View File

@@ -10,7 +10,7 @@
class="absolute inset-0"
/>
<img
v-if="cachedSrc"
v-show="isImageLoaded"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
@@ -77,8 +77,8 @@ const shouldLoad = computed(() => isIntersecting.value)
watch(
shouldLoad,
async (shouldLoadVal) => {
if (shouldLoadVal && src && !cachedSrc.value && !hasError.value) {
async (shouldLoad) => {
if (shouldLoad && 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 (!shouldLoadVal) {
} else if (!shouldLoad) {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}

View File

@@ -0,0 +1,256 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import NoResultsPlaceholder from './NoResultsPlaceholder.vue'
const meta: Meta<typeof NoResultsPlaceholder> = {
title: 'Components/Common/NoResultsPlaceholder',
component: NoResultsPlaceholder,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'NoResultsPlaceholder displays an empty state with optional icon, title, message, and action button. Built with PrimeVue Card component and customizable styling.'
}
}
},
argTypes: {
class: {
control: 'text',
description: 'Additional CSS classes to apply to the wrapper',
defaultValue: undefined
},
icon: {
control: 'text',
description: 'PrimeIcons icon class to display',
defaultValue: undefined
},
title: {
control: 'text',
description: 'Main heading text',
defaultValue: 'No Results'
},
message: {
control: 'text',
description: 'Descriptive message text (supports multi-line with \\n)',
defaultValue: 'No items found'
},
textClass: {
control: 'text',
description: 'Additional CSS classes for the message text',
defaultValue: undefined
},
buttonLabel: {
control: 'text',
description: 'Label for action button (button hidden if not provided)',
defaultValue: undefined
},
onAction: {
action: 'action',
description: 'Event emitted when action button is clicked'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof NoResultsPlaceholder>
export const Default: Story = {
args: {
title: 'No Results',
message: 'No items found'
},
parameters: {
docs: {
description: {
story: 'Basic placeholder with just title and message.'
}
}
}
}
export const WithIcon: Story = {
args: {
icon: 'pi pi-search',
title: 'No Search Results',
message: 'Try adjusting your search criteria or filters'
},
parameters: {
docs: {
description: {
story:
'Placeholder with a search icon to indicate empty search results.'
}
}
}
}
export const WithActionButton: Story = {
args: {
icon: 'pi pi-plus',
title: 'No Items',
message: 'Get started by creating your first item',
buttonLabel: 'Create Item'
},
parameters: {
docs: {
description: {
story:
'Placeholder with an action button to help users take the next step.'
}
}
}
}
export const MultilineMessage: Story = {
args: {
icon: 'pi pi-exclamation-triangle',
title: 'Connection Error',
message:
'Unable to load data from the server.\nPlease check your internet connection\nand try again.',
buttonLabel: 'Retry'
},
parameters: {
docs: {
description: {
story: 'Placeholder with multi-line message using newline characters.'
}
}
}
}
export const EmptyWorkflow: Story = {
args: {
icon: 'pi pi-sitemap',
title: 'No Workflows',
message:
'Create your first ComfyUI workflow to get started with image generation',
buttonLabel: 'New Workflow'
},
parameters: {
docs: {
description: {
story: 'Example for empty workflow state in ComfyUI context.'
}
}
}
}
export const EmptyModels: Story = {
args: {
icon: 'pi pi-download',
title: 'No Models Found',
message:
'Download models from the model manager to start generating images',
buttonLabel: 'Open Model Manager'
},
parameters: {
docs: {
description: {
story: 'Example for empty models state with download action.'
}
}
}
}
export const FilteredResults: Story = {
args: {
icon: 'pi pi-filter',
title: 'No Matching Results',
message:
'No items match your current filters.\nTry clearing some filters to see more results.',
buttonLabel: 'Clear Filters'
},
parameters: {
docs: {
description: {
story: 'Placeholder for filtered results with option to clear filters.'
}
}
}
}
export const CustomStyling: Story = {
args: {
class: 'custom-placeholder',
icon: 'pi pi-star',
title: 'No Favorites',
message: 'Mark items as favorites to see them here',
textClass: 'text-muted-foreground',
buttonLabel: 'Browse Items'
},
parameters: {
docs: {
description: {
story: 'Placeholder with custom CSS classes applied.'
}
}
}
}
// Interactive story to test action event
export const Interactive: Story = {
args: {
icon: 'pi pi-cog',
title: 'Configuration Required',
message: 'Complete the setup to continue',
buttonLabel: 'Configure'
},
parameters: {
docs: {
description: {
story:
'Interactive placeholder - click the button to see the action event in the Actions panel.'
}
}
}
}
// Gallery view showing different icon options
export const IconGallery: Story = {
render: () => ({
components: { NoResultsPlaceholder },
template: `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; padding: 20px;">
<NoResultsPlaceholder
icon="pi pi-search"
title="Search"
message="No search results"
/>
<NoResultsPlaceholder
icon="pi pi-inbox"
title="Empty Inbox"
message="No messages"
/>
<NoResultsPlaceholder
icon="pi pi-heart"
title="No Favorites"
message="No favorite items"
/>
<NoResultsPlaceholder
icon="pi pi-folder-open"
title="Empty Folder"
message="This folder is empty"
/>
<NoResultsPlaceholder
icon="pi pi-shopping-cart"
title="Empty Cart"
message="Your cart is empty"
/>
<NoResultsPlaceholder
icon="pi pi-users"
title="No Users"
message="No users found"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story: 'Gallery showing different icon options and use cases.'
}
}
}
}

View File

@@ -0,0 +1,203 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import RefreshButton from './RefreshButton.vue'
const meta: Meta<typeof RefreshButton> = {
title: 'Components/Common/RefreshButton',
component: RefreshButton,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'RefreshButton is an interactive button with loading state management. It shows a refresh icon that transforms into a progress spinner when active, using v-model for state control.'
}
}
},
argTypes: {
modelValue: {
control: 'boolean',
description: 'Active/loading state of the button (v-model)'
},
disabled: {
control: 'boolean',
description: 'Whether the button is disabled'
},
outlined: {
control: 'boolean',
description: 'Whether to use outlined button style'
},
severity: {
control: 'select',
options: ['secondary', 'success', 'info', 'warn', 'help', 'danger'],
description: 'PrimeVue severity level for button styling'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof RefreshButton>
const createStoryRender =
(initialState = false, asyncDuration = 2000) =>
(args: any) => ({
components: { RefreshButton },
setup() {
const isActive = ref(args.modelValue ?? initialState)
const actions = ref<string[]>([])
const logAction = (action: string) => {
const timestamp = new Date().toLocaleTimeString()
actions.value.unshift(`${action} (${timestamp})`)
if (actions.value.length > 5) actions.value.pop()
console.log(action)
}
const handleRefresh = async () => {
logAction('Refresh started')
isActive.value = true
await new Promise((resolve) => setTimeout(resolve, asyncDuration))
isActive.value = false
logAction('Refresh completed')
}
return { args, isActive, actions, handleRefresh }
},
template: `
<div style="padding: 20px;">
<RefreshButton
v-model="isActive"
v-bind="args"
@refresh="handleRefresh"
/>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createStoryRender(),
args: {
modelValue: false,
disabled: false,
outlined: true,
severity: 'secondary'
}
}
export const Active: Story = {
render: createStoryRender(true),
args: {
disabled: false,
outlined: true,
severity: 'secondary'
}
}
export const Disabled: Story = {
render: createStoryRender(),
args: {
disabled: true,
outlined: true,
severity: 'secondary'
}
}
export const Filled: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: false,
severity: 'secondary'
}
}
export const SuccessSeverity: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: true,
severity: 'success'
}
}
export const DangerSeverity: Story = {
render: createStoryRender(),
args: {
disabled: false,
outlined: true,
severity: 'danger'
}
}
// Simplified gallery showing all severities
export const SeverityGallery: Story = {
render: () => ({
components: { RefreshButton },
setup() {
const severities = [
'secondary',
'success',
'info',
'warn',
'help',
'danger'
]
const states = ref(Object.fromEntries(severities.map((s) => [s, false])))
const refresh = async (severity: string) => {
console.log(`Refreshing with ${severity} severity`)
states.value[severity] = true
await new Promise((resolve) => setTimeout(resolve, 2000))
states.value[severity] = false
}
return { severities, states, refresh }
},
template: `
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; padding: 20px;">
<div v-for="severity in severities" :key="severity" style="text-align: center;">
<RefreshButton
v-model="states[severity]"
:severity="severity"
@refresh="refresh(severity)"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666; text-transform: capitalize;">
{{ severity }}
</div>
</div>
</div>
`
})
}
// ComfyUI usage examples
export const WorkflowRefresh: Story = {
render: () => ({
components: { RefreshButton },
setup() {
const isRefreshing = ref(false)
const refreshWorkflows = async () => {
console.log('Refreshing workflows...')
isRefreshing.value = true
await new Promise((resolve) => setTimeout(resolve, 3000))
isRefreshing.value = false
console.log('Workflows refreshed!')
}
return { isRefreshing, refreshWorkflows }
},
template: `
<div style="display: flex; align-items: center; gap: 12px; padding: 20px;">
<span>Workflows:</span>
<RefreshButton v-model="isRefreshing" @refresh="refreshWorkflows" />
</div>
`
})
}

View File

@@ -32,7 +32,7 @@
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import type { PrimeVueSeverity } from '@/types/primeVueTypes'
import { PrimeVueSeverity } from '@/types/primeVueTypes'
const {
disabled,

View File

@@ -0,0 +1,265 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
const meta: Meta = {
title: 'Components/Common/SearchBox',
component: SearchBox as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'SearchBox provides a comprehensive search interface with debounced input, active filter chips, and optional filter button. Features automatic clear functionality and sophisticated event handling for search workflows.'
}
}
},
argTypes: {
modelValue: {
control: 'text',
description: 'Current search query text (v-model)'
},
placeholder: {
control: 'text',
description: 'Placeholder text for the search input'
},
icon: {
control: 'text',
description: 'PrimeIcons icon class for the search icon'
},
debounceTime: {
control: { type: 'number', min: 0, max: 1000, step: 50 },
description: 'Debounce delay in milliseconds for search events'
},
filterIcon: {
control: 'text',
description: 'Optional filter button icon (button hidden if not provided)'
},
filters: {
control: 'object',
description: 'Array of active filter chips to display'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
const createSearchBoxRender =
(initialFilters: any[] = []) =>
(args: any) => ({
components: { SearchBox },
setup() {
const searchQuery = ref(args.modelValue || '')
const filters = ref(args.filters || initialFilters)
const actions = ref<string[]>([])
const logAction = (action: string, data?: any) => {
const timestamp = new Date().toLocaleTimeString()
const message = data
? `${action}: "${data}" (${timestamp})`
: `${action} (${timestamp})`
actions.value.unshift(message)
if (actions.value.length > 5) actions.value.pop()
console.log(action, data)
}
const handleUpdate = (value: string) => {
searchQuery.value = value
logAction('Search text updated', value)
}
const handleSearch = (value: string, searchFilters: any[]) => {
logAction(
'Debounced search',
`"${value}" with ${searchFilters.length} filters`
)
}
const handleShowFilter = () => {
logAction('Filter button clicked')
}
const handleRemoveFilter = (filter: any) => {
const index = filters.value.findIndex((f: any) => f === filter)
if (index > -1) {
filters.value.splice(index, 1)
logAction('Filter removed', filter.label || filter)
}
}
return {
args,
searchQuery,
filters,
actions,
handleUpdate,
handleSearch,
handleShowFilter,
handleRemoveFilter
}
},
template: `
<div style="width: 400px; padding: 20px;">
<SearchBox
:modelValue="searchQuery"
v-bind="args"
:filters="filters"
@update:modelValue="handleUpdate"
@search="handleSearch"
@showFilter="handleShowFilter"
@removeFilter="handleRemoveFilter"
/>
<div v-if="actions.length > 0" style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 12px;">
<div style="font-weight: bold; margin-bottom: 8px;">Actions Log:</div>
<div v-for="action in actions" :key="action" style="margin: 2px 0;">{{ action }}</div>
</div>
</div>
`
})
export const Default: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Search nodes...',
icon: 'pi pi-search',
debounceTime: 300,
filters: []
}
}
export const WithFilters: Story = {
render: createSearchBoxRender([
{ label: 'Image', type: 'category' },
{ label: 'Sampling', type: 'category' },
{ label: 'Recent', type: 'sort' }
]),
args: {
modelValue: 'stable diffusion',
placeholder: 'Search models...',
icon: 'pi pi-search',
debounceTime: 300,
filterIcon: 'pi pi-filter'
}
}
export const WithFilterButton: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Search workflows...',
icon: 'pi pi-search',
debounceTime: 300,
filterIcon: 'pi pi-filter',
filters: []
}
}
export const FastDebounce: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Fast search (50ms debounce)...',
icon: 'pi pi-search',
debounceTime: 50,
filters: []
}
}
export const SlowDebounce: Story = {
render: createSearchBoxRender(),
args: {
modelValue: '',
placeholder: 'Slow search (1000ms debounce)...',
icon: 'pi pi-search',
debounceTime: 1000,
filters: []
}
}
// ComfyUI examples
export const NodeSearch: Story = {
render: () => ({
components: { SearchBox },
setup() {
const searchQuery = ref('')
const nodeFilters = ref([
{ label: 'Sampling', type: 'category' },
{ label: 'Popular', type: 'sort' }
])
const handleSearch = (value: string, filters: any[]) => {
console.log('Searching nodes:', { value, filters })
}
const handleRemoveFilter = (filter: any) => {
const index = nodeFilters.value.findIndex((f) => f === filter)
if (index > -1) {
nodeFilters.value.splice(index, 1)
}
}
return {
searchQuery,
nodeFilters,
handleSearch,
handleRemoveFilter
}
},
template: `
<div style="width: 300px;">
<div style="margin-bottom: 8px; font-weight: 600;">Node Library</div>
<SearchBox
v-model="searchQuery"
placeholder="Search nodes..."
icon="pi pi-box"
:debounceTime="300"
filterIcon="pi pi-filter"
:filters="nodeFilters"
@search="handleSearch"
@removeFilter="handleRemoveFilter"
/>
</div>
`
})
}
export const ModelSearch: Story = {
render: () => ({
components: { SearchBox },
setup() {
const searchQuery = ref('stable-diffusion')
const modelFilters = ref([
{ label: 'SDXL', type: 'version' },
{ label: 'Checkpoints', type: 'type' }
])
const handleSearch = (value: string, filters: any[]) => {
console.log('Searching models:', { value, filters })
}
return {
searchQuery,
modelFilters,
handleSearch
}
},
template: `
<div style="width: 350px;">
<div style="margin-bottom: 8px; font-weight: 600;">Model Manager</div>
<SearchBox
v-model="searchQuery"
placeholder="Search models..."
icon="pi pi-database"
:debounceTime="400"
filterIcon="pi pi-sliders-h"
:filters="modelFilters"
@search="handleSearch"
/>
</div>
`
})
}

View File

@@ -0,0 +1,279 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SearchFilterChip from './SearchFilterChip.vue'
const meta: Meta<typeof SearchFilterChip> = {
title: 'Components/Common/SearchFilterChip',
component: SearchFilterChip,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'SearchFilterChip displays a removable chip with a badge and text, commonly used for showing active filters in search interfaces. Built with PrimeVue Chip and Badge components.'
}
}
},
argTypes: {
text: {
control: 'text',
description: 'Main text content displayed on the chip',
defaultValue: 'Filter'
},
badge: {
control: 'text',
description: 'Badge text/number displayed before the main text',
defaultValue: '1'
},
badgeClass: {
control: 'select',
options: ['i-badge', 'o-badge', 'c-badge', 's-badge'],
description:
'CSS class for badge styling (i-badge: green, o-badge: red, c-badge: blue, s-badge: yellow)',
defaultValue: 'i-badge'
},
onRemove: {
description: 'Event emitted when the chip remove button is clicked'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof SearchFilterChip>
export const Default: Story = {
args: {
text: 'Active Filter',
badge: '5',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story: 'Default search filter chip with green badge.'
}
}
}
}
export const InputBadge: Story = {
args: {
text: 'Inputs',
badge: '3',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with green input badge (i-badge class).'
}
}
}
}
export const OutputBadge: Story = {
args: {
text: 'Outputs',
badge: '2',
badgeClass: 'o-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with red output badge (o-badge class).'
}
}
}
}
export const CategoryBadge: Story = {
args: {
text: 'Category',
badge: '8',
badgeClass: 'c-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with blue category badge (c-badge class).'
}
}
}
}
export const StatusBadge: Story = {
args: {
text: 'Status',
badge: '12',
badgeClass: 's-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with yellow status badge (s-badge class).'
}
}
}
}
export const LongText: Story = {
args: {
text: 'Very Long Filter Name That Might Wrap',
badge: '999+',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story:
'Filter chip with long text and large badge number to test layout.'
}
}
}
}
export const SingleCharacterBadge: Story = {
args: {
text: 'Model Type',
badge: 'A',
badgeClass: 'c-badge'
},
parameters: {
docs: {
description: {
story: 'Filter chip with single character badge.'
}
}
}
}
export const ComfyUIFilters: Story = {
render: () => ({
components: { SearchFilterChip },
methods: {
handleRemove: () => console.log('Filter removed')
},
template: `
<div style="display: flex; flex-wrap: wrap; gap: 8px; padding: 20px;">
<SearchFilterChip
text="Sampling Nodes"
badge="5"
badgeClass="i-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Image Outputs"
badge="3"
badgeClass="o-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Conditioning"
badge="12"
badgeClass="c-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="Advanced"
badge="7"
badgeClass="s-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="SDXL Models"
badge="24"
badgeClass="i-badge"
@remove="handleRemove"
/>
<SearchFilterChip
text="ControlNet"
badge="8"
badgeClass="o-badge"
@remove="handleRemove"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Example showing multiple filter chips as they might appear in ComfyUI search interface.'
}
}
}
}
export const Interactive: Story = {
args: {
text: 'Removable Filter',
badge: '42',
badgeClass: 'i-badge'
},
parameters: {
docs: {
description: {
story:
'Interactive chip - click the X button to see the remove event in the Actions panel.'
}
}
}
}
// Gallery showing all badge styles
export const BadgeStyleGallery: Story = {
render: () => ({
components: { SearchFilterChip },
methods: {
handleRemove: () => console.log('Filter removed')
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; padding: 20px; max-width: 400px;">
<div style="text-align: center;">
<SearchFilterChip
text="Input Badge"
badge="I"
badgeClass="i-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Green (i-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Output Badge"
badge="O"
badgeClass="o-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Red (o-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Category Badge"
badge="C"
badgeClass="c-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Blue (c-badge)</div>
</div>
<div style="text-align: center;">
<SearchFilterChip
text="Status Badge"
badge="S"
badgeClass="s-badge"
@remove="handleRemove"
/>
<div style="margin-top: 8px; font-size: 12px; color: #666;">Yellow (s-badge)</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story: 'Gallery showing all available badge styles and their colors.'
}
}
}
}

View File

@@ -20,22 +20,17 @@
<h2 class="text-2xl font-semibold mb-4">
{{ $t('g.devices') }}
</h2>
<div v-if="props.stats.devices && props.stats.devices.length > 0">
<TabView v-if="props.stats.devices.length > 1">
<TabPanel
v-for="device in props.stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
>
<DeviceInfo :device="device" />
</TabPanel>
</TabView>
<DeviceInfo v-else :device="props.stats.devices[0]" />
</div>
<div v-else class="text-yellow-600">
{{ $t('g.noDevicesDetected') }}
</div>
<TabView v-if="props.stats.devices.length > 1">
<TabPanel
v-for="device in props.stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
>
<DeviceInfo :device="device" />
</TabPanel>
</TabView>
<DeviceInfo v-else :device="props.stats.devices[0]" />
</div>
</div>
</template>
@@ -44,7 +39,7 @@
import Divider from 'primevue/divider'
import TabPanel from 'primevue/tabpanel'
import TabView from 'primevue/tabview'
import { computed, watchEffect } from 'vue'
import { computed } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import type { SystemStats } from '@/schemas/apiSchema'
@@ -76,15 +71,4 @@ const formatValue = (value: any, field: string) => {
}
return value
}
// Monitor for missing devices scenario for debugging
watchEffect(() => {
if (!props.stats?.devices || props.stats.devices.length === 0) {
console.warn('[SystemStatsPanel] No devices available in SystemStats:', {
hasDevices: !!props.stats?.devices,
deviceCount: props.stats?.devices?.length || 0,
statsStructure: props.stats ? Object.keys(props.stats) : null
})
}
})
</script>

View File

@@ -0,0 +1,250 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TextDivider from './TextDivider.vue'
const meta: Meta<typeof TextDivider> = {
title: 'Components/Common/TextDivider',
component: TextDivider,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'TextDivider combines text with a PrimeVue divider to create labeled section separators. The text can be positioned on either side of the divider line with various styling options.'
}
}
},
argTypes: {
text: {
control: 'text',
description: 'Text content to display alongside the divider',
defaultValue: 'Section'
},
position: {
control: 'select',
options: ['left', 'right'],
description: 'Position of text relative to the divider',
defaultValue: 'left'
},
align: {
control: 'select',
options: ['left', 'center', 'right', 'top', 'bottom'],
description: 'Alignment of the divider line',
defaultValue: 'center'
},
type: {
control: 'select',
options: ['solid', 'dashed', 'dotted'],
description: 'Style of the divider line',
defaultValue: 'solid'
},
layout: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Layout direction of the divider',
defaultValue: 'horizontal'
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof TextDivider>
export const Default: Story = {
args: {
text: 'Section Title',
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Default text divider with text on the left side of a solid horizontal line.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const RightPosition: Story = {
args: {
text: 'Section Title',
position: 'right',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with text positioned on the right side of the line.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const DashedStyle: Story = {
args: {
text: 'Dashed Section',
position: 'left',
align: 'center',
type: 'dashed',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with a dashed line style for a softer visual separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const DottedStyle: Story = {
args: {
text: 'Dotted Section',
position: 'right',
align: 'center',
type: 'dotted',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with a dotted line style for subtle content separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 400px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}
export const VerticalLayout: Story = {
args: {
text: 'Vertical',
position: 'left',
align: 'center',
type: 'solid',
layout: 'vertical'
},
parameters: {
docs: {
description: {
story:
'Text divider in vertical layout for side-by-side content separation.'
}
}
},
decorators: [
() => ({
template: `
<div style="height: 200px; display: flex; align-items: stretch; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-right: 15px; flex: 1;">
Left Content
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-left: 15px; flex: 1;">
Right Content
</div>
</div>
`
})
]
}
export const LongText: Story = {
args: {
text: 'Configuration Settings and Options',
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
},
parameters: {
docs: {
description: {
story:
'Text divider with longer text content to demonstrate text wrapping and spacing behavior.'
}
}
},
decorators: [
() => ({
template: `
<div style="width: 300px; padding: 20px;">
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-bottom: 15px;">
Content above divider
</div>
<story />
<div style="padding: 15px; background: rgba(100, 100, 100, 0.1); margin-top: 15px;">
Content below divider
</div>
</div>
`
})
]
}

View File

@@ -0,0 +1,651 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import TreeExplorer from './TreeExplorer.vue'
const meta: Meta = {
title: 'Components/Common/TreeExplorer',
component: TreeExplorer as any,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'TreeExplorer provides a sophisticated tree navigation component with expandable nodes, selection, context menus, drag-and-drop support, and customizable node rendering. Features folder operations, renaming, deletion, and advanced tree manipulation capabilities.'
}
}
},
argTypes: {
root: {
control: 'object',
description: 'Root tree node with hierarchical structure'
},
expandedKeys: {
control: 'object',
description: 'Object tracking which nodes are expanded (v-model)',
defaultValue: {}
},
selectionKeys: {
control: 'object',
description: 'Object tracking which nodes are selected (v-model)',
defaultValue: {}
},
class: {
control: 'text',
description: 'Additional CSS classes for the tree',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj
export const BasicTree: Story = {
render: (args: any) => ({
components: { TreeExplorer },
setup() {
return { args }
},
data() {
return {
expanded: {},
selected: {},
treeData: {
key: 'root',
label: 'Root',
children: [
{
key: 'workflows',
label: 'Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'portrait',
label: 'Portrait Generation.json',
icon: 'pi pi-file'
},
{
key: 'landscape',
label: 'Landscape SDXL.json',
icon: 'pi pi-file'
},
{ key: 'anime', label: 'Anime Style.json', icon: 'pi pi-file' }
]
},
{
key: 'models',
label: 'Models',
icon: 'pi pi-download',
children: [
{
key: 'checkpoints',
label: 'Checkpoints',
icon: 'pi pi-folder',
children: [
{
key: 'sdxl',
label: 'SDXL_base.safetensors',
icon: 'pi pi-file'
},
{
key: 'sd15',
label: 'SD_1.5.safetensors',
icon: 'pi pi-file'
}
]
},
{
key: 'lora',
label: 'LoRA',
icon: 'pi pi-folder',
children: [
{
key: 'portrait_lora',
label: 'portrait_enhance.safetensors',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'outputs',
label: 'Outputs',
icon: 'pi pi-images',
children: [
{
key: 'output1',
label: 'ComfyUI_00001_.png',
icon: 'pi pi-image'
},
{
key: 'output2',
label: 'ComfyUI_00002_.png',
icon: 'pi pi-image'
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Node clicked:', node.label)
},
handleNodeDelete(node: any) {
console.log('Node delete requested:', node.label)
},
handleContextMenu(node: any, _event: MouseEvent) {
console.log('Context menu on node:', node.label)
}
},
template: `
<div style="padding: 20px; width: 400px; height: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">ComfyUI File Explorer</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Navigate through workflows, models, and outputs
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 400px; overflow: auto;">
<TreeExplorer
:root="treeData"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
@nodeDelete="handleNodeDelete"
@contextMenu="handleContextMenu"
/>
</div>
<div style="margin-top: 12px; font-size: 12px; color: #6b7280;">
Expanded: {{ Object.keys(expanded).length }} | Selected: {{ Object.keys(selected).length }}
</div>
</div>
`
}),
args: {
expandedKeys: { workflows: true, models: true },
selectionKeys: { portrait: true }
},
parameters: {
docs: {
description: {
story:
'Basic TreeExplorer with ComfyUI file structure showing workflows, models, and outputs.'
}
}
}
}
export const EmptyTree: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: {},
selected: {},
emptyTree: {
key: 'empty-root',
label: 'Empty Workspace',
children: []
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, event: MouseEvent) {
console.log('Empty tree node clicked:', node, event)
}
},
template: `
<div style="padding: 20px; width: 350px; height: 300px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Empty Workspace</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Empty tree explorer state
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 200px; display: flex; align-items: center; justify-content: center;">
<TreeExplorer
:root="emptyTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
<div style="color: #9ca3af; font-style: italic; text-align: center;">
<i class="pi pi-folder-open" style="display: block; font-size: 24px; margin-bottom: 8px;"></i>
No items in workspace
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Empty TreeExplorer showing the state when no items are present in the workspace.'
}
}
}
}
export const DeepHierarchy: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { workflows: true, 'stable-diffusion': true },
selected: {},
deepTree: {
key: 'root',
label: 'Projects',
children: [
{
key: 'workflows',
label: 'Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'stable-diffusion',
label: 'Stable Diffusion',
icon: 'pi pi-folder',
children: [
{
key: 'portraits',
label: 'Portraits',
icon: 'pi pi-folder',
children: [
{
key: 'realistic',
label: 'Realistic Portrait.json',
icon: 'pi pi-file'
},
{
key: 'artistic',
label: 'Artistic Portrait.json',
icon: 'pi pi-file'
}
]
},
{
key: 'landscapes',
label: 'Landscapes',
icon: 'pi pi-folder',
children: [
{
key: 'nature',
label: 'Nature Scene.json',
icon: 'pi pi-file'
},
{
key: 'urban',
label: 'Urban Environment.json',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'controlnet',
label: 'ControlNet',
icon: 'pi pi-folder',
children: [
{
key: 'canny',
label: 'Canny Edge.json',
icon: 'pi pi-file'
},
{
key: 'depth',
label: 'Depth Map.json',
icon: 'pi pi-file'
}
]
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Deep tree node clicked:', node.label)
}
},
template: `
<div style="padding: 20px; width: 400px; height: 600px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Deep Hierarchy</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Multi-level nested folder structure with organized workflows
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
<TreeExplorer
:root="deepTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Deep hierarchical TreeExplorer showing multi-level folder organization with workflows.'
}
}
}
}
export const InteractiveOperations: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { workflows: true },
selected: {},
operationLog: [],
interactiveTree: {
key: 'root',
label: 'Interactive Workspace',
children: [
{
key: 'workflows',
label: 'My Workflows',
icon: 'pi pi-sitemap',
children: [
{
key: 'workflow1',
label: 'Image Generation.json',
icon: 'pi pi-file',
handleRename: function (newName: string) {
console.log(`Renaming workflow to: ${newName}`)
},
handleDelete: function () {
console.log('Deleting workflow')
}
},
{
key: 'workflow2',
label: 'Video Processing.json',
icon: 'pi pi-file',
handleRename: function (newName: string) {
console.log(`Renaming workflow to: ${newName}`)
},
handleDelete: function () {
console.log('Deleting workflow')
}
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
this.operationLog.unshift(`Clicked: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
},
handleNodeDelete(node: any) {
this.operationLog.unshift(`Delete requested: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
},
handleContextMenu(node: any, _event: MouseEvent) {
this.operationLog.unshift(`Context menu: ${node.label}`)
if (this.operationLog.length > 8) this.operationLog.pop()
}
},
template: `
<div style="padding: 20px; width: 500px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Interactive Operations</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Click nodes, right-click for context menu, test selection behavior
</p>
</div>
<div style="display: flex; gap: 16px;">
<div style="flex: 1; border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
<TreeExplorer
:root="interactiveTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
@nodeDelete="handleNodeDelete"
@contextMenu="handleContextMenu"
/>
</div>
<div style="flex: 1; background: rgba(0,0,0,0.05); border-radius: 8px; padding: 12px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">Operation Log:</div>
<div v-if="operationLog.length === 0" style="font-style: italic; color: #9ca3af; font-size: 12px;">
No operations yet...
</div>
<div v-for="(entry, index) in operationLog" :key="index" style="font-size: 12px; color: #4b5563; margin-bottom: 2px; font-family: monospace;">
{{ entry }}
</div>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Interactive TreeExplorer demonstrating click, context menu, and selection operations with live logging.'
}
}
}
}
export const WorkflowManager: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { 'workflow-library': true, 'my-workflows': true },
selected: {},
workflowTree: {
key: 'root',
label: 'Workflow Manager',
children: [
{
key: 'my-workflows',
label: 'My Workflows',
icon: 'pi pi-user',
children: [
{
key: 'draft1',
label: 'Draft - SDXL Portrait.json',
icon: 'pi pi-file-edit'
},
{
key: 'final1',
label: 'Final - Product Shots.json',
icon: 'pi pi-file'
},
{
key: 'temp1',
label: 'Temp - Testing.json',
icon: 'pi pi-clock'
}
]
},
{
key: 'workflow-library',
label: 'Workflow Library',
icon: 'pi pi-book',
children: [
{
key: 'community',
label: 'Community',
icon: 'pi pi-users',
children: [
{
key: 'popular1',
label: 'SDXL Ultimate.json',
icon: 'pi pi-star-fill'
},
{
key: 'popular2',
label: 'ControlNet Pro.json',
icon: 'pi pi-star-fill'
}
]
},
{
key: 'templates',
label: 'Templates',
icon: 'pi pi-clone',
children: [
{
key: 'template1',
label: 'Basic Generation.json',
icon: 'pi pi-file'
},
{
key: 'template2',
label: 'Img2Img Template.json',
icon: 'pi pi-file'
}
]
}
]
},
{
key: 'recent',
label: 'Recent',
icon: 'pi pi-history',
children: [
{
key: 'recent1',
label: 'Last Session.json',
icon: 'pi pi-clock'
},
{
key: 'recent2',
label: 'Quick Test.json',
icon: 'pi pi-clock'
}
]
}
]
} as TreeExplorerNode
}
},
methods: {
handleNodeClick(node: any, _event: MouseEvent) {
console.log('Workflow selected:', node.label)
}
},
template: `
<div style="padding: 20px; width: 450px; height: 600px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Workflow Manager</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Organized workflow library with categories, templates, and recent files
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 500px; overflow: auto;">
<TreeExplorer
:root="workflowTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
@nodeClick="handleNodeClick"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Realistic workflow manager showing organized hierarchy with categories, templates, and recent files.'
}
}
}
}
export const CompactView: Story = {
render: () => ({
components: { TreeExplorer },
data() {
return {
expanded: { models: true },
selected: {},
compactTree: {
key: 'root',
label: 'Models',
children: [
{
key: 'models',
label: 'Checkpoints',
icon: 'pi pi-download',
children: [
{
key: 'model1',
label: 'SDXL_base.safetensors',
icon: 'pi pi-file'
},
{
key: 'model2',
label: 'SD_1.5_pruned.safetensors',
icon: 'pi pi-file'
},
{
key: 'model3',
label: 'Realistic_Vision_V5.safetensors',
icon: 'pi pi-file'
},
{
key: 'model4',
label: 'AnythingV5_v3.safetensors',
icon: 'pi pi-file'
}
]
}
]
} as TreeExplorerNode
}
},
template: `
<div style="padding: 20px; width: 300px; height: 400px;">
<div style="margin-bottom: 16px;">
<h3 style="margin: 0 0 8px 0; color: #374151;">Compact Model List</h3>
<p style="margin: 0; font-size: 14px; color: #6b7280;">
Compact view for smaller spaces
</p>
</div>
<div style="border: 1px solid #e2e8f0; border-radius: 8px; background: white; height: 300px; overflow: auto;">
<TreeExplorer
:root="compactTree"
v-model:expandedKeys="expanded"
v-model:selectionKeys="selected"
class="text-sm"
/>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Compact TreeExplorer view for smaller interface areas with minimal spacing.'
}
}
}
}

View File

@@ -1,7 +1,7 @@
<template>
<Tree
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"
class="tree-explorer py-0 px-2 2xl:px-4"
:class="props.class"
:value="renderedRoot.children"

View File

@@ -9,8 +9,10 @@ import { createI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { InjectKeyHandleEditLabelFunction } from '@/types/treeExplorerTypes'
import {
InjectKeyHandleEditLabelFunction,
RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
// Create a mock i18n instance
const i18n = createI18n({

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import UserAvatar from './UserAvatar.vue'
const meta: Meta<typeof UserAvatar> = {
title: 'Components/Common/UserAvatar',
component: UserAvatar,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'UserAvatar displays a circular avatar image with fallback to a user icon when no image is provided or when the image fails to load. Built on top of PrimeVue Avatar component.'
}
}
},
argTypes: {
photoUrl: {
control: 'text',
description:
'URL of the user photo to display. Falls back to user icon if null, undefined, or fails to load',
defaultValue: null
},
ariaLabel: {
control: 'text',
description: 'Accessibility label for screen readers',
defaultValue: undefined
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof UserAvatar>
export const Default: Story = {
args: {
photoUrl: null,
ariaLabel: 'User avatar'
},
parameters: {
docs: {
description: {
story: 'Default avatar with no image - shows user icon fallback.'
}
}
}
}
export const WithValidImage: Story = {
args: {
photoUrl:
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face',
ariaLabel: 'John Doe avatar'
},
parameters: {
docs: {
description: {
story: 'Avatar with a valid image URL displaying a user photo.'
}
}
}
}
export const WithBrokenImage: Story = {
args: {
photoUrl: 'https://example.com/nonexistent-image.jpg',
ariaLabel: 'User with broken image'
},
parameters: {
docs: {
description: {
story:
'Avatar with a broken image URL - automatically falls back to user icon when image fails to load.'
}
}
}
}
export const WithCustomAriaLabel: Story = {
args: {
photoUrl:
'https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face',
ariaLabel: 'Sarah Johnson, Project Manager'
},
parameters: {
docs: {
description: {
story:
'Avatar with custom accessibility label for better screen reader experience.'
}
}
}
}
export const EmptyString: Story = {
args: {
photoUrl: '',
ariaLabel: 'User with empty photo URL'
},
parameters: {
docs: {
description: {
story:
'Avatar with empty string photo URL - treats empty string as no image.'
}
}
}
}
export const UndefinedUrl: Story = {
args: {
photoUrl: undefined,
ariaLabel: 'User with undefined photo URL'
},
parameters: {
docs: {
description: {
story: 'Avatar with undefined photo URL - shows default user icon.'
}
}
}
}
// Gallery view showing different states
export const Gallery: Story = {
render: () => ({
components: { UserAvatar },
template: `
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center; padding: 20px;">
<div style="text-align: center;">
<UserAvatar :photoUrl="null" ariaLabel="No image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">No Image</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face" ariaLabel="Valid image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Valid Image</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://images.unsplash.com/photo-1494790108755-2616b612b586?w=100&h=100&fit=crop&crop=face" ariaLabel="Another valid image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Another Valid</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="https://example.com/broken.jpg" ariaLabel="Broken image" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Broken URL</div>
</div>
<div style="text-align: center;">
<UserAvatar photoUrl="" ariaLabel="Empty string" />
<div style="margin-top: 8px; font-size: 12px; color: #666;">Empty String</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Gallery showing different avatar states side by side for comparison.'
}
}
}
}

View File

@@ -138,7 +138,7 @@ const allMissingNodesInstalled = computed(() => {
})
// Watch for completion and close dialog
watch(allMissingNodesInstalled, async (allInstalled) => {
if (allInstalled && showInstallAllButton.value) {
if (allInstalled) {
// Use nextTick to ensure state updates are complete
await nextTick()

View File

@@ -1,5 +1,4 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'

View File

@@ -148,7 +148,7 @@ import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
import ApiKeyForm from './signin/ApiKeyForm.vue'

View File

@@ -17,8 +17,7 @@
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { Form, FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import { ref } from 'vue'

View File

@@ -16,7 +16,7 @@
<div class="flex flex-1 relative overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
v-model:selected-tab="selectedTab"
v-model:selectedTab="selectedTab"
:tabs="tabs"
/>
<div
@@ -57,9 +57,9 @@
</IconButton>
</div>
<RegistrySearchBar
v-model:search-query="searchQuery"
v-model:search-mode="searchMode"
v-model:sort-field="sortField"
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
v-model:sortField="sortField"
:search-results="searchResults"
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
@@ -160,7 +160,7 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { TabItem } from '@/types/comfyManagerTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
const { initialTab } = defineProps<{
initialTab?: ManagerTab

View File

@@ -169,7 +169,7 @@ import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import type {
import {
ConflictDetail,
ConflictDetectionResult
} from '@/types/conflictDetectionTypes'

View File

@@ -19,7 +19,7 @@
import Message from 'primevue/message'
import { computed, inject } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
type PackVersionStatus = components['schemas']['NodeVersionStatus']

View File

@@ -1,5 +1,4 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'

View File

@@ -1,5 +1,4 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'

View File

@@ -93,8 +93,8 @@ import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import type { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
import { isSemVer } from '@/utils/formatUtil'

View File

@@ -1,5 +1,4 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'

View File

@@ -39,7 +39,7 @@ import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
import type { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256

View File

@@ -31,11 +31,13 @@ import { useConflictDetection } from '@/composables/useConflictDetection'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { ButtonSize } from '@/types/buttonTypes'
import { ButtonSize } from '@/types/buttonTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import type { components as ManagerComponents } from '@/types/generatedManagerTypes'
import {
type ConflictDetail,
ConflictDetectionResult
} from '@/types/conflictDetectionTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']

View File

@@ -17,9 +17,9 @@
<script setup lang="ts">
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { ButtonSize } from '@/types/buttonTypes'
import { ButtonSize } from '@/types/buttonTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']

View File

@@ -75,7 +75,7 @@ import { useImportFailedDetection } from '@/composables/useImportFailedDetection
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'

View File

@@ -50,7 +50,7 @@ import PackUninstallButton from '@/components/dialog/content/manager/button/Pack
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'

View File

@@ -67,7 +67,7 @@ import { usePacksSelection } from '@/composables/nodePack/usePacksSelection'
import { usePacksStatus } from '@/composables/nodePack/usePacksStatus'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'

View File

@@ -48,7 +48,7 @@ import { computed, inject, ref, watchEffect } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import WarningTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/WarningTabPanel.vue'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'

View File

@@ -8,7 +8,7 @@
</template>
<script setup lang="ts">
const { value = 'N/A', label } = defineProps<{
const { value = 'N/A', label = 'N/A' } = defineProps<{
label: string
value?: string | number
}>()

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
import DescriptionTabPanel from './DescriptionTabPanel.vue'

View File

@@ -29,7 +29,7 @@ import { useI18n } from 'vue-i18n'
import InfoTextSection, {
type TextSection
} from '@/components/dialog/content/manager/infoPanel/InfoTextSection.vue'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
import { isValidUrl } from '@/utils/formatUtil'
const { t } = useI18n()

View File

@@ -34,7 +34,7 @@ import { computed, ref, shallowRef, useId } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { components, operations } from '@/types/comfyRegistryTypes'
import { registryToFrontendV2NodeDef } from '@/utils/mapperUtil'
type ListComfyNodesResponse =

View File

@@ -29,8 +29,8 @@ import { computed } from 'vue'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { t } from '@/i18n'
import type { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { components } from '@/types/comfyRegistryTypes'
import { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
const { nodePack, conflictResult } = defineProps<{

View File

@@ -37,7 +37,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'

View File

@@ -37,7 +37,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'

View File

@@ -19,7 +19,7 @@
<script setup lang="ts">
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
const {
nodePacks,

View File

@@ -41,12 +41,12 @@
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model:model-value="searchMode"
v-model:modelValue="searchMode"
:options="filterOptions"
:label="$t('g.filter')"
/>
<SearchFilterDropdown
v-model:model-value="sortField"
v-model:modelValue="sortField"
:options="availableSortOptions"
:label="$t('g.sort')"
/>
@@ -62,8 +62,9 @@
<script setup lang="ts">
import { stubTrue } from 'es-toolkit/compat'
import type { AutoCompleteOptionSelectEvent } from 'primevue/autocomplete'
import AutoComplete from 'primevue/autocomplete'
import AutoComplete, {
AutoCompleteOptionSelectEvent
} from 'primevue/autocomplete'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -76,7 +77,7 @@ import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
SearchMode,

View File

@@ -1,5 +1,4 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'

View File

@@ -96,8 +96,8 @@ import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import type { AuditLog } from '@/services/customerEventsService'
import {
AuditLog,
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'

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