Compare commits

...

31 Commits

Author SHA1 Message Date
Terry Jia
b8d2b8fad2 test 2025-12-10 08:40:20 -05:00
Comfy Org PR Bot
b52b2bbc30 1.35.1 (#7318)
Patch version increment to 1.35.1

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7318-1-35-1-2c56d73d3650810ea05bf2c5734130a3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-10 02:42:23 -08:00
Terry Jia
424bd21559 fix: move selected groups when dragging nodes in vueNodes mode (#7306)
## Summary
Captures selected groups at drag start and moves them using frame delta
to match LiteGraph's behavior.

Litegraph doesn't have this issue.

## Screenshots (if applicable)
### Before


https://github.com/user-attachments/assets/0e4ff907-376e-438b-aa89-106c146a8ac1


### After


https://github.com/user-attachments/assets/d954da99-3468-4bd8-9e1a-835e1a90a3bd

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7306-fix-move-selected-groups-when-dragging-nodes-in-vueNodes-mode-2c56d73d3650816a83efd11bbc36262e)
by [Unito](https://www.unito.io)
2025-12-09 23:32:47 -07:00
Johnpaul Chiwetelu
04286c033a hotfix: stabilize flaky workflow sidebar browser tests (#7280)
## Summary
- Fix flaky workflow sidebar browser tests that were failing in headless
mode
- Add retry logic for menu hover operations in Topbar
- Add proper timing/wait helpers for dialog masks and workflow service
completion
- Fix test isolation issues in setupWorkflowsDirectory and drop workflow
test

## Test plan
- [x] Run `pnpm test:browser --
browser_tests/tests/sidebar/workflows.spec.ts` multiple times
- [x] Verify the 3 previously failing tests now pass consistently:
  - "Can overwrite other workflows with save as"
  - "Can rename nested workflow from opened workflow item"  
  - "Can drop workflow from workflows sidebar"

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7280-hotfix-stabilize-flaky-workflow-sidebar-browser-tests-2c46d73d365081c5b3badfafe35a63dc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-09 23:30:40 -07:00
Benjamin Lu
59429cbe56 fix(desktop-ui): resolve linting and typecheck errors (#7271)
Fixes linting configuration and type errors in apps/desktop-ui.

## Changes
- Updated `eslint.config.ts` to use absolute path for `.oxlintrc.json`
resolution.
- Fixed `import-x` errors in `InstallFooter.vue`, `refUtil.ts`, and
`DesktopDialogView.vue`.
- Fixed i18n raw text error in `NotSupportedView.vue` via
eslint-disable.
- Fixed type inference issue in `i18n.ts` allowing dynamic locale
switching.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7271-fix-desktop-ui-resolve-linting-and-typecheck-errors-2c46d73d3650817cbb66cc7b1dc670a8)
by [Unito](https://www.unito.io)
2025-12-09 23:27:11 -07:00
AustinMroz
eb04178e33 Fix compatibility with older browsers (#7205)
Resolves #7174

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7205-Fix-compatibility-with-older-browsers-2c16d73d365081fcaa3ce2693107791a)
by [Unito](https://www.unito.io)
2025-12-09 23:19:53 -07:00
Terry Jia
b88d96d6cc fix: node shape not reactive in vueNodes mode (#7302)
## Summary

add node shape support in vueNodes

fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/7144

## Screenshots (if applicable)


https://github.com/user-attachments/assets/df8a4fa6-5686-435d-a814-4fe3990f7e69

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7302-fix-node-shape-not-reactive-in-vueNodes-mode-2c56d73d3650811c9ef5e4fe49c94f55)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-12-09 23:19:03 -07:00
Simula_r
dedc77786f fix: loading state to show loader only if it takes more than 250ms (#7268)
## Summary

To prevent the flash of "loading..." and "calculating dimensions" when
loading cached images only set loading set if longer than 250ms

## Changes

- **What**: ImagePreview.vue
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

The retrigger loading is because i have throttled 4g slow in the demo.
So cache takes time. Normally this doesn't happen.


https://github.com/user-attachments/assets/335ca7e4-4ce1-43dd-b7d0-9ee88e187069

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7268-fix-loading-state-to-show-loader-only-if-it-takes-more-than-150ms-2c46d73d365081a6b311f78ba3e1cffd)
by [Unito](https://www.unito.io)
2025-12-09 23:17:43 -07:00
Christian Byrne
356ebe538f style: redesign TopUpCredits dialog (#7305)
Redesigned the TopUpCredits dialog to match Figma design specifications
with proper layout, typography, colors and selection states. Updated
dialog to use workflow-aware messaging, removed header, applied design
system tokens, and integrated subscription renewal dates. Modified
credit packages to use clean USD amounts with realistic video estimates
and fixed button disabled states to show blue with 30% opacity per Figma
design.

| Before | After |
|
---------------------------------------------------------------------------------------------------------------------------------------------
|
---------------------------------------------------------------------------------------------------------------------------------------------
|
| <img width="675" height="863" alt="Screenshot from 2025-12-09
18-08-21"
src="https://github.com/user-attachments/assets/331c7a48-74ae-4a58-b70f-aa476c3fc87c"
/> | <img width="675" height="863" alt="Screenshot from 2025-12-09
18-06-23"
src="https://github.com/user-attachments/assets/dcb7b358-6045-4c89-82ed-3283a20eea89"
/>
 |
2025-12-09 21:30:56 -07:00
Christian Byrne
2c06c58621 feat: update subscription panel with tier-based design and improved UX (#7307)
Transforms the subscription credits panel from legacy design to
tier-based layout with Creator tier details, updated typography using
design system tokens, improved responsive credit breakdown layout, and
better subscription management flow. Updates credit formatting to remove
unnecessary decimals and Credits suffix, replaces external Stripe
billing portal with inline dialog, and reorganizes plan benefits section
with proper v-for structure matching Figma specifications.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7307-feat-update-subscription-panel-with-tier-based-design-and-improved-UX-2c56d73d365081ef8b63e262a6822c72)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-09 21:30:06 -07:00
Christian Byrne
c13343b8fb style: redesign user popover with improved layout and integration with design system (#7303)
Implements new Figma design for the user popover with cleaner row-based
layout, proper design system tokens, and improved spacing. Replaces
PrimeVue icons with Lucide icons, fixes credits display to show whole
numbers without unnecessary decimals, updates menu item order to match
design specifications, and ensures consistent hover states and
typography throughout. All styling now uses Tailwind classes with proper
semantic design tokens instead of inline styles.

| Before | After |
|
---------------------------------------------------------------------------------------------------------------------------------------------
|
---------------------------------------------------------------------------------------------------------------------------------------------
|
| <img width="815" height="973" alt="image"
src="https://github.com/user-attachments/assets/b2c15fa0-f545-4dcf-b224-cee846885337"
/> | <img width="815" height="973" alt="image"
src="https://github.com/user-attachments/assets/1f0bf488-5e15-4bb9-84b7-019cdd5105ae"
/> |

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-09 20:47:57 -07:00
Luke Mino-Altherr
ce4837a57c feat: display and upload Civitai preview images in model upload flow (#7274)
## Summary
Stores and displays base64-encoded preview images from Civitai during
the model upload flow, uploading the preview as a separate asset linked
to the model.

## Changes
- **Schema**: Added `preview_image` field to `AssetMetadata` schema
- **Service**: Added `uploadAssetFromBase64` method to convert base64
data to blob and upload via FormData
- **Upload Flow**: Modified wizard to first upload preview image as
asset, then link it to model via `preview_id`
- **UI**: Display 56x56px preview thumbnail alongside model filename in
confirmation and success steps

## Review Focus
- Base64 to blob conversion and FormData upload implementation
- Sequential upload flow (preview first, then model with preview_id
reference)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7274-feat-display-and-upload-Civitai-preview-images-in-model-upload-flow-2c46d73d365081ff9b74c1791d23f6dd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 16:32:55 -08:00
Benjamin Lu
2903560416 Move cancel button into actionbar (#7297)
## Summary
Move the interrupt control into the actionbar so cancellation sits with
the run controls.

## Changes
- add a cancel button to the actionbar with the existing interrupt
tooltip and disabled state
- remove the cancel button and related execution wiring from the top
menu section to avoid duplication

## Review Focus
- spacing/hover states of the new cancel control in both docked and
floating modes

## Screenshots (if applicable)
- n/a

Tests: pnpm typecheck; pnpm lint:fix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7297-Move-cancel-button-into-actionbar-2c46d73d36508198b00cf011390289f6)
by [Unito](https://www.unito.io)
2025-12-09 14:32:03 -08:00
Luke Mino-Altherr
6850c45d63 [feat] Add ownership filter to model browser (#7201)
## Summary
Adds a dropdown filter to the model browser that allows users to filter
assets by ownership (All, My models, Public models), based on the
`is_immutable` property.

## Changes
- **Filter UI**: Added ownership dropdown in
[AssetFilterBar.vue](src/platform/assets/components/AssetFilterBar.vue#L30-L38)
that only appears when user has uploaded models
- **Filter Logic**: Implemented `filterByOwnership` function in
[useAssetBrowser.ts](src/platform/assets/composables/useAssetBrowser.ts#L38-L45)
to filter by `is_immutable` property
- **i18n**: Added translation strings for ownership filter options
- **Tests**: Added comprehensive tests for ownership filtering in both
composable and component test files

## Review Focus
- The ownership filter visibility logic correctly checks for mutable
assets (`!is_immutable`)
- Default filter value is 'all' to show all models initially
- Filter integrates cleanly with existing file format and base model
filters

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7201-feat-Add-ownership-filter-to-model-browser-2c16d73d365081f280f6d1e42e5400af)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2025-12-09 13:52:33 -08:00
Alexander Brown
2b9f7ecedf 🤖 Testing section update (#7295)
## Summary

Standing on the shoulders of giants.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7295-Testing-section-update-2c46d73d3650818f935bcb2ac65d9830)
by [Unito](https://www.unito.io)
2025-12-09 13:21:21 -07:00
Terry Jia
73b08acfe0 fix: Note/MarkdownNote node color change not reactive in vueNodes mode (#7294)
## Summary

Move color/bgcolor initialization from class field overrides to
constructor to preserve LGraphNodeProperties getter/setter
instrumentation.

Class field overrides were replacing the reactive property descriptors
set by the parent constructor, preventing change events from firing.

issue found while tesing in
https://github.com/Comfy-Org/ComfyUI_frontend/issues/3449

## Screenshots
Before


https://github.com/user-attachments/assets/04499a3a-15c2-44fd-9819-6dd5f6849f20


After


https://github.com/user-attachments/assets/ba93278b-9761-4d45-abb3-2a57ff95a900

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7294-fix-Note-MarkdownNote-node-color-change-not-reactive-in-vueNodes-mode-2c46d73d3650818f8ee6f6f0c0e61d39)
by [Unito](https://www.unito.io)
2025-12-09 09:17:07 -08:00
Christian Byrne
c524ce3a2f make jojodecayz and bigcat88 owners of partner node pricing (#7284)
## Summary

Allows partner node team to quickly approve/merge each other's PRs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7284-make-jojodecayz-and-bigcat88-owners-of-partner-node-pricing-2c46d73d365081b08295d3204f8ca13c)
by [Unito](https://www.unito.io)
2025-12-09 07:55:57 -05:00
Christian Byrne
aef40834f3 add shared comfy credit conversion helpers (#7061)
Introduces cents<->usd<->credit converters plus basic formatters and
adds test. Lays groundwork to start converting UI components into
displaying comfy credits.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7061-add-shared-comfy-credit-conversion-helpers-2bb6d73d3650810bb34fdf9bb3fc115b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 05:11:27 -07:00
Christian Byrne
8209f5a108 feat: add Stripe pricing table integration for subscription dialog (conditional on feature flag) (#7288)
Integrates Stripe's pricing table web component into the subscription
dialog when the subscription_tiers_enabled feature flag is active. The
implementation includes a new StripePricingTable component that loads
Stripe's pricing table script and renders the table with proper error
handling and loading states. The subscription dialog now displays the
Stripe pricing table with contact us and enterprise links, using a
1100px width that balances multi-column layout with visual design.
Configuration supports environment variables, remote config, and window
config for the Stripe publishable key and pricing table ID.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7288-feat-add-Stripe-pricing-table-integration-for-subscription-dialog-conditional-on-featur-2c46d73d365081fa9d93c213df118996)
by [Unito](https://www.unito.io)
2025-12-09 04:45:45 -07:00
Christian Byrne
77e453db36 [fix] Fall back to current minor when next minor branch doesn't exist (#7286)
## Summary
- When the next minor branch (e.g., `core/1.35`) doesn't exist yet, fall
back to current minor (`core/1.34`) for patch releases instead of
failing
- Fixes the weekly release workflow failure when ComfyUI is on version
1.33.x and `core/1.34` doesn't exist yet

## Test plan
- [x] Tested locally with version where next minor exists (1.33.5 →
finds core/1.34)
- [x] Tested fallback when next minor doesn't exist (1.34.7 → falls back
to core/1.34)
- [x] Tested error case when neither branch exists

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7286-fix-Fall-back-to-current-minor-when-next-minor-branch-doesn-t-exist-2c46d73d365081009762c732954e148d)
by [Unito](https://www.unito.io)
2025-12-09 02:45:56 -07:00
Christian Byrne
d3e9e15f07 change credits icons and tooltips (conditional on feature flag) (#7276)
This PR changes the credits icons and tooltips based on state of the
`subscription_tiers_enabled` feature flag.

When the flag is enabled (or undefined -- for local), the dollar icon is
replaced with the lucide-component icon in UserCredit and node price
badges (Partner Nodes), and a new tooltip row appears in
CurrentUserPopover displaying "Credits have been unified" with a
detailed hover tooltip explaining the credit unification across Partner
Nodes and Cloud workflows.

<img width="539" height="535" alt="image"
src="https://github.com/user-attachments/assets/7e952f9b-0abb-4979-85b7-0eecdeaf808c"
/>

Related:

- https://github.com/Comfy-Org/ComfyUI_frontend/pull/6115 (borrows badge
implementation from this PR)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7276-change-credits-icons-and-tooltips-conditional-on-feature-flag-2c46d73d365081809a6afd5861018a15)
by [Unito](https://www.unito.io)
2025-12-09 02:41:32 -07:00
Christian Byrne
4d3f918e8e feat: Enable system notifications on cloud (#7277)
Re-enables the system notification popup for cloud distribution,
allowing cloud devs to notify cloud users about new features and updates
without requiring a new release.

Cloud now fetches release notes from the "cloud" project (instead of
"comfyui") and uses the `cloud_version` field for version comparison.
Since cloud versions are git hashes rather than semver, a helper handles
both formats gracefully.

The "What's New" popup is enabled for cloud, while the update toast and
red dot indicator remain desktop-only since cloud auto-updates and
doesn't require user action.

You can test this by doing `pnpm dev:cloud` and you will see a
notification I added (for testing):

<img width="1891" height="2077" alt="image"
src="https://github.com/user-attachments/assets/6599a6dc-a3e1-406f-a22d-14262be1f258"
/>

Content is controlled by non-devs at cms.comfy.org.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7277-feat-Enable-system-notifications-on-cloud-2c46d73d365081bcb33cd79ec18faefe)
by [Unito](https://www.unito.io)
2025-12-09 02:32:13 -07:00
Rizumu Ayaka
82fc96155f fix: mouse accidentally sticks and drag the node (#7186)
https://github.com/user-attachments/assets/88b76852-0050-4f16-a371-916af5232517

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-12-09 01:01:00 -07:00
Benjamin Lu
79a6421329 Fix cloud queue cancel to target specific jobs (#7176)
## Summary
- switch QueueProgressOverlay cancel action to target explicit prompt
ids on cloud via /api/queue delete
- keep non-cloud behavior using /interrupt for local installs
- ensure prompt id list generation is straightforward

## Testing
- pnpm typecheck
- pnpm lint:fix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7176-Fix-cloud-queue-cancel-to-target-specific-jobs-2c06d73d365081b38ab2f81d71f62186)
by [Unito](https://www.unito.io)
2025-12-09 00:59:43 -07:00
Benjamin Lu
42314d227f Enable shift-drop context menu test (#7140)
## Summary
- turn the shift-drop context menu release action test back on
- keep the drag distance shorter to reduce flake risk while preserving
behavior

## Testing
- pnpm typecheck
- pnpm lint:fix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7140-Enable-shift-drop-context-menu-test-2be6d73d36508104a913d55279d4d3e5)
by [Unito](https://www.unito.io)
2025-12-09 00:56:05 -07:00
Christian Byrne
8f300c7163 fix: release notifications unit tests missing i18n mocks (#7281)
## Summary
- Fix missing `i18n` export in `@/i18n` mock for WhatsNewPopup tests
- Fix missing `createI18n` export in `vue-i18n` mock for
ReleaseNotificationToast tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7281-fix-release-notifications-unit-tests-missing-i18n-mocks-2c46d73d365081e595eadaca6a4ace5d)
by [Unito](https://www.unito.io)
2025-12-09 00:35:37 -07:00
Alexander Brown
5b91434ac4 Cleanup: Sidebar Tabs component and style alignment (#7215)
## Summary

Unify the current sidebar tabs, structurally and aesthetically.

## Changes

- Removes the Assets only Layout
- Standardizes the title styling and spacing across the tabs.

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7215-WIP-Sidebar-Tabs-component-and-style-alignment-2c26d73d3650817193bfd752e0d0bbde)
by [Unito](https://www.unito.io)
2025-12-09 00:33:08 -07:00
Christian Byrne
51a336fd36 style: update ui and design of system notification components (what's new, new release notification, help center) (#6300)
## Summary

Migrated help center and release notification components from hardcoded
colors to semantic design tokens for automatic light/dark theme support.

<img width="808" height="874" alt="Selection_2298"
src="https://github.com/user-attachments/assets/c7fb956e-700b-49df-bba0-b85705e89ce7"
/>

<img width="852" height="710" alt="Selection_2265"
src="https://github.com/user-attachments/assets/618205e1-5068-499d-80ab-72626b32d7e1"
/>

<img width="493" height="838" alt="Screenshot from 2025-10-25 21-46-11"
src="https://github.com/user-attachments/assets/7b696673-ec19-4a16-a0b5-ca744ae62fe1"
/>

<img width="493" height="838" alt="Screenshot from 2025-10-25 21-46-25"
src="https://github.com/user-attachments/assets/2767d722-a0e1-426d-82d9-6d5a59f373ee"
/>

## Changes

- **What**: Replaced hardcoded hex/rgb colors with semantic tokens in
HelpCenterMenuContent, WhatsNewPopup, and ReleaseNotificationToast
components
- **Design System**: Added `--interface-menu-surface` and
`--interface-menu-stroke` tokens to style.css for consistent menu
theming
- **UX**: Updated help center menu structure - added "Give Feedback"
button, renamed "Help & Feedback" to "Help & Support", switched to
Lucide icons (except Discord brand logo), added external-link icons

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6300-style-update-ui-and-design-of-system-notification-components-what-s-new-new-release-no-2986d73d365081238458ea7d304b641e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-09 00:07:02 -07:00
AustinMroz
ad630cfbfe Add label to open subgraph button (#7244)
Also 
- Updates button icon and text color
- Removes the subgraphNode icon, so that space available for node title
is not reduced.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/e63593d6-2dad-4779-aed5-e0c1e544fb17"
/>| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/b1ad034b-dc1a-4ce0-8ca1-b78acdf8ab0e"
/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7244-Add-label-to-open-subgraph-button-2c36d73d3650815a8602dce456350b39)
by [Unito](https://www.unito.io)
2025-12-08 21:47:16 -08:00
Benjamin Lu
41eb45754b Fix desktop menu docs links regression (#7181)
## Summary
- make `useExternalLink` rely on the global i18n locale so it can be
used safely outside setup
- restore `electronAdapter` to use the shared `useExternalLink` helper
for docs URLs and static links

## Motivation
Desktop menu items disappeared because a top-level call to
`useExternalLink` in `electronAdapter` triggered `useI18n` at
module-eval time, throwing and blocking extension registration. By
making the composable global-locale-only and using it in
`electronAdapter`, the module can load without setup context while
preserving link behavior.

## Testing
- pnpm typecheck
- pnpm lint:fix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7181-Fix-desktop-menu-docs-links-regression-2c06d73d36508157ae48cff078b9173e)
by [Unito](https://www.unito.io)
2025-12-08 22:20:07 -07:00
Comfy Org PR Bot
f0a99a0a75 1.35.0 (#7270)
Minor version increment to 1.35.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7270-1-35-0-2c46d73d3650815c84fcd30f0e2d291d)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-08 20:22:21 -07:00
117 changed files with 4347 additions and 1850 deletions

View File

@@ -42,3 +42,7 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# Stripe pricing table configuration (used by feature-flagged subscription tiers UI)
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123
# VITE_STRIPE_PRICING_TABLE_ID=prctbl_123

View File

@@ -73,24 +73,6 @@ The project uses **Nx** for build orchestration and task management
- composables `useXyz.ts`
- Pinia stores `*Store.ts`
## Testing Guidelines
- Frameworks:
- Vitest (unit/component, happy-dom)
- Playwright (E2E)
- Test files:
- Unit/Component: `**/*.test.ts`
- E2E: `browser_tests/**/*.spec.ts`
- Litegraph Specific: `src/lib/litegraph/test/`
- Coverage: text/json/html reporters enabled
- aim to cover critical logic and new features
- Playwright:
- optional tags like `@mobile`, `@2x` are respected by config
- Tests to avoid
- Change detector tests
- e.g. a test that just asserts that the defaults are certain values
- Tests that are dependent on non-behavioral features like utility classes or styles
- Redundant tests
## Commit & Pull Request Guidelines
@@ -161,7 +143,7 @@ The project uses **Nx** for build orchestration and task management
14. Write code that is expressive and self-documenting to the furthest degree possible. This reduces the need for code comments which can get out of sync with the code itself. Try to avoid comments unless absolutely necessary
15. Do not add or retain redundant comments, clean as you go
16. Whenever a new piece of code is written, the author should ask themselves 'is there a simpler way to introduce the same functionality?'. If the answer is yes, the simpler course should be chosen
17. Refactoring should be used to make complex code simpler
17. [Refactoring](https://refactoring.com/catalog/) should be used to make complex code simpler
18. Try to minimize the surface area (exported values) of each module and composable
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`
20. Keep functions short and functional
@@ -170,6 +152,42 @@ The project uses **Nx** for build orchestration and task management
23. Favor pure functions (especially testable ones)
24. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
## Testing Guidelines
- Frameworks:
- Vitest (unit/component, happy-dom)
- Playwright (E2E)
- Test files:
- Unit/Component: `**/*.test.ts`
- E2E: `browser_tests/**/*.spec.ts`
- Litegraph Specific: `src/lib/litegraph/test/`
### General
1. Do not write change detector tests
e.g. a test that just asserts that the defaults are certain values
2. Do not write tests that are dependent on non-behavioral features like utility classes or styles
3. Be parsimonious in testing, do not write redundant tests
See <https://tidyfirst.substack.com/p/composable-tests>
4. [Dont Mock What You Dont Own](https://hynek.me/articles/what-to-mock-in-5-mins/)
### Vitest / Unit Tests
1. Do not write tests that just test the mocks
Ensure that the tests fail when the code itself would behave in a way that was not expected or desired
2. For mocking, leverage [Vitest's utilities](https://vitest.dev/guide/mocking.html) where possible
3. Keep your module mocks contained
Do not use global mutable state within the test file
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
5. Aim for behavioral coverage of critical and new features
### Playwright / Browser / E2E Tests
1. Follow the Best Practices described [in the Playwright documentation](https://playwright.dev/docs/best-practices)
2. Do not use waitForTimeout, use Locator actions and [retrying assertions](https://playwright.dev/docs/test-assertions#auto-retrying-assertions)
3. Tags like `@mobile`, `@2x` are respected by config and should be used for relevant tests
## External Resources
- Vue: <https://vuejs.org/api/>
@@ -182,6 +200,7 @@ The project uses **Nx** for build orchestration and task management
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Project Philosophy

View File

@@ -25,6 +25,9 @@
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu

View File

@@ -87,6 +87,8 @@
}
},
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
},

View File

@@ -40,7 +40,8 @@
<script setup lang="ts">
import type { PassThrough } from '@primevue/core'
import Button from 'primevue/button'
import Step, { type StepPassThroughOptions } from 'primevue/step'
import Step from 'primevue/step'
import type { StepPassThroughOptions } from 'primevue/step'
import StepList from 'primevue/steplist'
defineProps<{

View File

@@ -155,12 +155,14 @@ export async function loadLocale(locale: string): Promise<void> {
}
// Only include English in the initial bundle
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)
}
const enMessages = buildLocale(en, enNodes, enCommands, enSettings)
// Type for locale messages - inferred from the English locale structure
type LocaleMessages = typeof messages.en
type LocaleMessages = typeof enMessages
const messages: Record<string, LocaleMessages> = {
en: enMessages
}
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2

View File

@@ -1,5 +1,6 @@
import { useTimeout } from '@vueuse/core'
import { type Ref, computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
/**
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.

View File

@@ -29,7 +29,8 @@ import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button'
import { useRoute } from 'vue-router'
import { type DialogAction, getDialog } from '@/constants/desktopDialogs'
import { getDialog } from '@/constants/desktopDialogs'
import type { DialogAction } from '@/constants/desktopDialogs'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'

View File

@@ -5,7 +5,7 @@
<img
class="sad-girl"
src="/assets/images/sad_girl.png"
alt="Sad girl illustration"
:alt="$t('notSupported.illustrationAlt')"
/>
<div class="no-drag sad-text flex items-center">

View File

@@ -126,6 +126,20 @@ class ConfirmDialog {
const loc = this[locator]
await expect(loc).toBeVisible()
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() => window['app']?.extensionManager?.workflow?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
@@ -242,6 +256,9 @@ export class ComfyPage {
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
// Wait for Vue to re-render the workflow list
await this.nextFrame()
}
async setupUser(username: string) {

View File

@@ -137,6 +137,13 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
await this.page.keyboard.type(newName)
await this.page.keyboard.press('Enter')
// Wait for workflow service to finish renaming
await this.page.waitForFunction(
() => !window['app']?.extensionManager?.workflow?.isBusy,
undefined,
{ timeout: 3000 }
)
}
async insertWorkflow(locator: Locator) {

View File

@@ -92,9 +92,26 @@ export class Topbar {
)
// Wait for the dialog to close.
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
if (await confirmationDialog.isVisible()) {
return
}
}
async openTopbarMenu() {
// If menu is already open, close it first to reset state
const isAlreadyOpen = await this.menuLocator.isVisible()
if (isAlreadyOpen) {
// Click outside the menu to close it properly
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
}
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
return this.menuLocator
@@ -162,15 +179,36 @@ export class Topbar {
await topLevelMenu.hover()
// Hover over top-level menu with retry logic for flaky submenu appearance
const submenu = this.getVisibleSubmenu()
try {
await submenu.waitFor({ state: 'visible', timeout: 1000 })
} catch {
// Click outside to reset, then reopen menu
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
// Re-hover on top-level menu to trigger submenu
await topLevelMenu.hover()
await submenu.waitFor({ state: 'visible', timeout: 1000 })
}
let currentMenu = topLevelMenu
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = currentMenu
.locator(
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
)
const menuItem = submenu
.locator(`.p-tieredmenu-item:has-text("${commandName}")`)
.first()
await menuItem.waitFor({ state: 'visible' })
// For the last item, click it
if (i === path.length - 1) {
await menuItem.click()
return
}
// Otherwise, hover to open nested submenu
await menuItem.hover()
currentMenu = menuItem
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -50,7 +50,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section shows the release
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show the release version
@@ -79,7 +79,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section shows no releases
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show "No recent releases" message
@@ -125,7 +125,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Should show no releases due to error
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(
whatsNewSection.locator('text=No recent releases')
).toBeVisible()
@@ -175,7 +175,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is hidden
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
// Should not show any popups or toasts
@@ -260,7 +260,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show the release
@@ -308,7 +308,7 @@ test.describe('Release Notifications', () => {
await helpCenterButton.click()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Close help center
@@ -359,7 +359,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Section should be hidden regardless of empty releases
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
})
})

View File

@@ -340,6 +340,11 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
// Wait for workflow to appear in Browse section after sync
const workflowItem =
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
await expect(workflowItem).toBeVisible({ timeout: 3000 })
const nodeCount = await comfyPage.getGraphNodesCount()
// Get the bounding box of the canvas element
@@ -358,6 +363,10 @@ test.describe('Workflows sidebar', () => {
'#graph-canvas',
{ targetPosition }
)
expect(await comfyPage.getGraphNodesCount()).toBe(nodeCount * 2)
// Wait for nodes to be inserted after drag-drop with retryable assertion
await expect
.poll(() => comfyPage.getGraphNodesCount(), { timeout: 3000 })
.toBe(nodeCount * 2)
})
})

View File

@@ -828,55 +828,55 @@ test.describe('Vue Node Link Interaction', () => {
})
test.describe('Release actions (Shift-drop)', () => {
test.fixme(
'Context menu opens and endpoint is pinned on Shift-drop',
async ({ comfyPage, comfyMouse }) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
const dropPos = { x: outputCenter.x + 90, y: outputCenter.y - 70 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Context menu should be visible
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
// Pinned endpoint should not change with mouse movement while menu is open
const before = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(before).not.toBeNull()
// Move mouse elsewhere and verify snap position is unchanged
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
const after = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(after).toEqual(before)
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
)
// Context menu should be visible
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
// Pinned endpoint should not change with mouse movement while menu is open
const before = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(before).not.toBeNull()
// Move mouse elsewhere and verify snap position is unchanged
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
const after = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(after).toEqual(before)
})
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
comfyPage,

View File

@@ -15,6 +15,7 @@ import {
parser as tseslintParser
} from 'typescript-eslint'
import vueParser from 'vue-eslint-parser'
import path from 'node:path'
const extraFileExtensions = ['.vue']
@@ -292,6 +293,9 @@ export default defineConfig([
'no-console': 'off'
}
},
// Turn off ESLint rules that are already handled by oxlint
...oxlint.buildFromOxlintConfigFile('./.oxlintrc.json')
...oxlint.buildFromOxlintConfigFile(
path.resolve(import.meta.dirname, '.oxlintrc.json')
)
])

2
global.d.ts vendored
View File

@@ -13,6 +13,8 @@ interface Window {
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
stripe_publishable_key?: string
stripe_pricing_table_id?: string
firebase_config?: {
apiKey: string
authDomain: string

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.34.7",
"version": "1.35.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -34,6 +34,7 @@
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "oxlint src --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",
"preinstall": "pnpm dlx only-allow pnpm",
@@ -46,6 +47,7 @@
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
"zipdist": "node scripts/zipdist.js",
"clean": "nx reset"
},

View File

@@ -89,6 +89,8 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
@@ -183,9 +185,13 @@
--interface-menu-component-surface-hovered: var(--color-smoke-200);
--interface-menu-component-surface-selected: var(--color-smoke-400);
--interface-menu-keybind-surface-default: var(--color-smoke-500);
--interface-menu-surface: var(--color-white);
--interface-menu-stroke: var(--color-smoke-600);
--interface-panel-surface: var(--color-white);
--interface-stroke: var(--color-smoke-300);
--nav-background: var(--color-white);
--node-border: var(--color-smoke-300);
@@ -301,6 +307,8 @@
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
--interface-menu-surface: var(--color-charcoal-800);
--interface-menu-stroke: var(--color-ash-800);
--interface-panel-surface: var(--color-charcoal-800);
--interface-stroke: var(--color-charcoal-400);
@@ -416,6 +424,8 @@
--color-interface-menu-keybind-surface-default: var(
--interface-menu-keybind-surface-default
);
--color-interface-menu-surface: var(--interface-menu-surface);
--color-interface-menu-stroke: var(--interface-menu-stroke);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
--color-interface-panel-selected-surface: var(

View File

@@ -134,22 +134,38 @@ function resolveRelease(
const [major, currentMinor, patch] = currentVersion.split('.').map(Number)
// Calculate target minor version (next minor)
const targetMinor = currentMinor + 1
const targetBranch = `core/1.${targetMinor}`
// Check if target branch exists in frontend repo
// Fetch all branches
exec('git fetch origin', frontendRepoPath)
const branchExists = exec(
// Try next minor first, fall back to current minor if not available
let targetMinor = currentMinor + 1
let targetBranch = `core/1.${targetMinor}`
const nextMinorExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
)
if (!branchExists) {
console.error(
`Target branch ${targetBranch} does not exist in frontend repo`
if (!nextMinorExists) {
// Fall back to current minor for patch releases
targetMinor = currentMinor
targetBranch = `core/1.${targetMinor}`
const currentMinorExists = exec(
`git rev-parse --verify origin/${targetBranch}`,
frontendRepoPath
)
if (!currentMinorExists) {
console.error(
`Neither core/1.${currentMinor + 1} nor core/1.${currentMinor} branches exist in frontend repo`
)
return null
}
console.error(
`Next minor branch core/1.${currentMinor + 1} not found, falling back to core/1.${currentMinor} for patch release`
)
return null
}
// Get latest patch tag for target minor

View File

@@ -0,0 +1,125 @@
const DEFAULT_NUMBER_FORMAT: Intl.NumberFormatOptions = {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}
const formatNumber = ({
value,
locale,
options
}: {
value: number
locale?: string
options?: Intl.NumberFormatOptions
}): string => {
const merged: Intl.NumberFormatOptions = {
...DEFAULT_NUMBER_FORMAT,
...options
}
if (
typeof merged.maximumFractionDigits === 'number' &&
typeof merged.minimumFractionDigits === 'number' &&
merged.maximumFractionDigits < merged.minimumFractionDigits
) {
merged.minimumFractionDigits = merged.maximumFractionDigits
}
return new Intl.NumberFormat(locale, merged).format(value)
}
export const CREDITS_PER_USD = 211
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
export const usdToCents = (usd: number): number => Math.round(usd * 100)
export const centsToCredits = (cents: number): number =>
Math.round(cents * COMFY_CREDIT_RATE_CENTS)
export const creditsToCents = (credits: number): number =>
Math.round(credits / COMFY_CREDIT_RATE_CENTS)
export const usdToCredits = (usd: number): number =>
Math.round(usd * CREDITS_PER_USD)
export const creditsToUsd = (credits: number): number =>
Math.round((credits / CREDITS_PER_USD) * 100) / 100
export type FormatOptions = {
value: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export type FormatFromCentsOptions = {
cents: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export type FormatFromUsdOptions = {
usd: number
locale?: string
numberOptions?: Intl.NumberFormatOptions
}
export const formatCredits = ({
value,
locale,
numberOptions
}: FormatOptions): string =>
formatNumber({ value, locale, options: numberOptions })
export const formatCreditsFromCents = ({
cents,
locale,
numberOptions
}: FormatFromCentsOptions): string =>
formatCredits({
value: centsToCredits(cents),
locale,
numberOptions
})
export const formatCreditsFromUsd = ({
usd,
locale,
numberOptions
}: FormatFromUsdOptions): string =>
formatCredits({
value: usdToCredits(usd),
locale,
numberOptions
})
export const formatUsd = ({
value,
locale,
numberOptions
}: FormatOptions): string =>
formatNumber({
value,
locale,
options: numberOptions
})
export const formatUsdFromCents = ({
cents,
locale,
numberOptions
}: FormatFromCentsOptions): string =>
formatUsd({
value: cents / 100,
locale,
numberOptions
})
/**
* Clamps a USD value to the allowed range for credit purchases
* @param value - The USD amount to clamp
* @returns The clamped value between $1 and $1000, or 0 if NaN
*/
export const clampUsd = (value: number): number => {
if (Number.isNaN(value)) return 0
return Math.min(1000, Math.max(1, value))
}

View File

@@ -20,17 +20,6 @@
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="mr-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
@@ -87,8 +76,6 @@ import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -96,8 +83,6 @@ import { isElectron } from '@/utils/envUtil'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const executionStore = useExecutionStore()
const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
@@ -108,13 +93,9 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
@@ -131,11 +112,6 @@ onMounted(() => {
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
</script>
<style scoped>

View File

@@ -30,6 +30,17 @@
/>
<ComfyRunButton />
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
</div>
</Panel>
</div>
@@ -43,17 +54,24 @@ import {
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
@@ -250,6 +268,16 @@ watch(isDragging, (dragging) => {
isMouseOverDropZone.value = false
}
})
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const actionbarClass = computed(() =>
cn(
'w-[200px] border-dashed border-blue-500 opacity-80',

View File

@@ -10,12 +10,12 @@
class="bg-transparent"
>
<div class="flex w-full justify-between">
<div class="tabs-container">
<div class="tabs-container font-inter">
<Tab
v-for="tab in bottomPanelStore.bottomPanelTabs"
:key="tab.id"
:value="tab.id"
class="m-1 mx-2 border-none"
class="m-1 mx-2 border-none font-inter"
:class="{
'tab-list-single-item':
bottomPanelStore.bottomPanelTabs.length === 1

View File

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

View File

@@ -8,12 +8,24 @@
</div>
<div v-else class="flex items-center gap-1">
<Tag
v-if="!showCreditsOnly"
severity="secondary"
icon="pi pi-dollar"
rounded
class="p-1 text-amber-400"
/>
<div :class="textClass">{{ formattedBalance }}</div>
>
<template #icon>
<i
:class="
flags.subscriptionTiersEnabled
? 'icon-[lucide--component]'
: 'pi pi-dollar'
"
/>
</template>
</Tag>
<div :class="textClass">
{{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }}
</div>
</div>
</template>
@@ -21,19 +33,39 @@
import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const { textClass } = defineProps<{
const { textClass, showCreditsOnly } = defineProps<{
textClass?: string
showCreditsOnly?: boolean
}>()
const authStore = useFirebaseAuthStore()
const { flags } = useFeatureFlags()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const { t, locale } = useI18n()
const formattedBalance = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
})
return `${amount} ${t('credits.credits')}`
})
const formattedCreditsOnly = computed(() => {
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
})
return amount
})
</script>

View File

@@ -1,5 +1,74 @@
<template>
<div class="flex w-96 flex-col gap-10 p-2">
<!-- New Credits Design (default) -->
<div v-if="useNewDesign" class="flex w-112 flex-col gap-8 p-8">
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-white m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h1>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0 w-96">
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
</div>
<div v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</div>
<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" show-credits-only />
<span class="text-sm text-muted-foreground">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
</div>
</div>
<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-muted-foreground">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
<CreditTopUpOption
v-for="option in creditOptions"
:key="option.credits"
:credits="option.credits"
:description="option.description"
:selected="selectedCredits === option.credits"
@select="selectedCredits = option.credits"
/>
</div>
<div class="text-xs text-muted-foreground w-96">
{{ $t('credits.topUp.templateNote') }}
</div>
</div>
<!-- Buy Button -->
<Button
:disabled="!selectedCredits || loading"
:loading="loading"
severity="primary"
:label="$t('credits.topUp.buy')"
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
:pt="{ label: { class: 'text-white' } }"
@click="handleBuy"
/>
</div>
<!-- Legacy Design -->
<div v-else class="flex w-96 flex-col gap-10 p-2">
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
<h1 class="my-0 text-2xl leading-normal font-medium">
{{ $t('credits.topUp.insufficientTitle') }}
@@ -34,14 +103,14 @@
>{{ $t('credits.topUp.quickPurchase') }}:</span
>
<div class="grid grid-cols-[2fr_1fr] gap-2">
<CreditTopUpOption
<LegacyCreditTopUpOption
v-for="amount in amountOptions"
:key="amount"
:amount="amount"
:preselected="amount === preselectedAmountOption"
/>
<CreditTopUpOption :amount="100" :preselected="false" editable />
<LegacyCreditTopUpOption :amount="100" :preselected="false" editable />
</div>
</div>
</div>
@@ -49,11 +118,28 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
creditsToUsd,
formatCredits,
formatUsd
} from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
import LegacyCreditTopUpOption from './credit/LegacyCreditTopUpOption.vue'
interface CreditOption {
credits: number
description: string
}
const {
isInsufficientCredits = false,
@@ -65,7 +151,74 @@ const {
preselectedAmountOption?: number
}>()
const { flags } = useFeatureFlags()
const { formattedRenewalDate } = useSubscription()
// Use feature flag to determine design - defaults to true (new design)
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
const { t, locale } = useI18n()
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const toast = useToast()
const selectedCredits = ref<number | null>(null)
const loading = ref(false)
const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 41 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 82 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 184 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 412 })
}
]
const handleBuy = async () => {
if (!selectedCredits.value) return
loading.value = true
try {
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
detail: t('credits.topUp.purchaseSuccessDetail', {
credits: formatCredits({
value: selectedCredits.value,
locale: locale.value
}),
amount: `$${formatUsd({ value: usdAmount, locale: locale.value })}`
}),
life: 3000
})
} catch (error) {
console.error('Purchase failed:', error)
const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}
const handleSeeDetails = async () => {
await authActions.accessBillingPortal()

View File

@@ -1,81 +1,45 @@
<template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="p-1 text-amber-400"
/>
<InputNumber
v-if="editable"
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
pt:pc-input-text:root="w-24"
@blur="(e: InputNumberBlurEvent) => (customAmount = Number(e.value))"
@input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
/>
<span v-else class="text-xl">{{ amount }}</span>
<div
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
:class="[
selected
? 'bg-secondary-background border-2 border-border-default'
: 'bg-component-node-disabled hover:bg-secondary-background border-2 border-transparent'
]"
@click="$emit('select')"
>
<span class="text-base font-bold text-white">
{{ formattedCredits }}
</span>
<span class="text-sm font-normal text-white">
{{ description }}
</span>
</div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
import { formatCredits } from '@/base/credits/comfyCredits'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
const { credits, description, selected } = defineProps<{
credits: number
description: string
selected: boolean
}>()
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
defineEmits<{
select: []
}>()
const handleBuyNow = async () => {
const creditAmount = editable ? customAmount.value : amount
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
const { locale } = useI18n()
loading.value = true
await authActions.purchaseCredits(creditAmount)
loading.value = false
didClickBuyNow.value = true
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
const formattedCredits = computed(() => {
return formatCredits({
value: credits,
locale: locale.value,
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
})
})
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-wallet"
rounded
class="p-1 text-amber-400"
/>
<div v-if="editable" class="flex items-center gap-2">
<InputNumber
v-model="customAmount"
:min="1"
:max="1000"
:step="1"
show-buttons
:allow-empty="false"
:highlight-on-focus="true"
prefix="$"
pt:pc-input-text:root="w-28"
@blur="
(e: InputNumberBlurEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
@input="
(e: InputNumberInputEvent) =>
(customAmount = clampUsd(Number(e.value)))
"
/>
<span class="text-xs text-muted">{{ formattedCredits }}</span>
</div>
<div v-else class="flex flex-col leading-tight">
<span class="text-xl font-semibold">{{ formattedCredits }}</span>
<span class="text-xs text-muted">{{ formattedUsd }}</span>
</div>
</div>
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<Button
v-else
:severity="preselected ? 'primary' : 'secondary'"
:outlined="!preselected"
:label="$t('credits.topUp.buyNow')"
@click="handleBuyNow"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import type {
InputNumberBlurEvent,
InputNumberInputEvent
} from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
clampUsd,
formatCreditsFromUsd,
formatUsd
} from '@/base/credits/comfyCredits'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
preselected,
editable = false
} = defineProps<{
amount: number
preselected: boolean
editable?: boolean
}>()
const customAmount = ref(amount)
const didClickBuyNow = ref(false)
const loading = ref(false)
const { t, locale } = useI18n()
const displayUsdAmount = computed(() =>
editable ? clampUsd(Number(customAmount.value)) : clampUsd(amount)
)
const formattedCredits = computed(
() =>
`${formatCreditsFromUsd({
usd: displayUsdAmount.value,
locale: locale.value
})} ${t('credits.credits')}`
)
const formattedUsd = computed(
() => `$${formatUsd({ value: displayUsdAmount.value, locale: locale.value })}`
)
const handleBuyNow = async () => {
const creditAmount = displayUsdAmount.value
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
loading.value = true
try {
await authActions.purchaseCredits(creditAmount)
didClickBuyNow.value = true
} finally {
loading.value = false
}
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authActions.fetchBalance()
}
})
</script>

View File

@@ -1,5 +1,6 @@
<template>
<TabPanel value="Credits" class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}

View File

@@ -1,38 +1,52 @@
<template>
<div
class="help-center-menu"
class="help-center-menu flex flex-col items-start gap-1"
role="menu"
:aria-label="$t('helpCenter.helpFeedback')"
:aria-label="$t('help.helpCenterMenu')"
>
<!-- Main Menu Items -->
<nav class="help-menu-section" role="menubar">
<button
v-for="menuItem in menuItems"
v-show="menuItem.visible !== false"
:key="menuItem.key"
type="button"
class="help-menu-item"
:class="{ 'more-item': menuItem.key === 'more' }"
role="menuitem"
@click="menuItem.action"
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<div class="help-menu-icon-container">
<div class="help-menu-icon">
<component
:is="menuItem.icon"
v-if="typeof menuItem.icon === 'object'"
:size="16"
/>
<i v-else :class="menuItem.icon" />
<div class="w-full">
<nav class="flex w-full flex-col gap-2" role="menubar">
<button
v-for="menuItem in menuItems"
v-show="menuItem.visible !== false"
:key="menuItem.key"
type="button"
class="help-menu-item"
:class="{ 'more-item': menuItem.key === 'more' }"
role="menuitem"
@click="menuItem.action"
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<div class="help-menu-icon-container">
<div class="help-menu-icon">
<component
:is="menuItem.icon"
v-if="typeof menuItem.icon === 'object'"
:size="16"
/>
<i v-else :class="menuItem.icon" />
</div>
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
</div>
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
</div>
<span class="menu-label">{{ menuItem.label }}</span>
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
</button>
</nav>
<span class="menu-label">{{ menuItem.label }}</span>
<i
v-if="menuItem.showExternalIcon"
class="icon-[lucide--external-link] text-primary w-4 h-4 ml-auto"
/>
<i
v-if="menuItem.key === 'more'"
class="pi pi-chevron-right ml-auto"
/>
</button>
</nav>
<div
class="flex h-4 flex-col items-center justify-between self-stretch p-2"
>
<div class="w-full border-b border-interface-menu-stroke" />
</div>
</div>
<!-- More Submenu -->
<Teleport to="body">
@@ -68,26 +82,34 @@
</Teleport>
<!-- What's New Section -->
<section v-if="showVersionUpdates" class="whats-new-section">
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
<section
v-if="showVersionUpdates"
class="w-full"
data-testid="whats-new-section"
>
<h3
class="section-description flex items-center gap-2.5 self-stretch px-8 pt-2 pb-2"
>
{{ $t('helpCenter.whatsNew') }}
</h3>
<!-- Release Items -->
<div
v-if="hasReleases"
role="group"
:aria-label="$t('helpCenter.recentReleases')"
:aria-label="$t('help.recentReleases')"
>
<article
v-for="release in releaseStore.recentReleases"
:key="release.id || release.version"
class="help-menu-item release-menu-item"
class="release-menu-item flex h-12 min-h-6 cursor-pointer items-center gap-2 self-stretch rounded p-2 transition-colors hover:bg-interface-menu-component-surface-hovered"
role="button"
tabindex="0"
@click="onReleaseClick(release)"
@keydown.enter="onReleaseClick(release)"
@keydown.space.prevent="onReleaseClick(release)"
>
<i class="pi pi-refresh help-menu-icon" aria-hidden="true" />
<i class="help-menu-icon icon-[lucide--package]" aria-hidden="true" />
<div class="release-content">
<span class="release-title">
{{
@@ -106,13 +128,6 @@
</span>
</time>
</div>
<Button
v-if="shouldShowUpdateButton(release)"
:label="$t('helpCenter.updateAvailable')"
size="small"
class="update-button"
@click.stop="onUpdate(release)"
/>
</article>
</div>
@@ -137,7 +152,6 @@
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type { CSSProperties, Component } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -166,6 +180,7 @@ interface MenuItem {
type?: 'item' | 'divider'
items?: MenuItem[]
showRedDot?: boolean
showExternalIcon?: boolean
}
// Constants
@@ -192,6 +207,9 @@ const commandStore = useCommandStore()
const settingStore = useSettingStore()
const telemetry = useTelemetry()
// Track when help center was opened
const openedAt = ref(Date.now())
// Emits
const emit = defineEmits<{
close: []
@@ -202,7 +220,6 @@ const isSubmenuVisible = ref(false)
const submenuRef = ref<HTMLElement | null>(null)
const submenuStyle = ref<CSSProperties>({})
let hoverTimeout: number | null = null
const openedAt = ref<number>(Date.now())
// Computed
const hasReleases = computed(() => releaseStore.releases.length > 0)
@@ -273,11 +290,34 @@ const moreMenuItem = computed(() =>
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{
key: 'feedback',
type: 'item',
icon: 'icon-[lucide--clipboard-pen]',
label: t('helpCenter.feedback'),
action: () => {
trackResourceClick('help_feedback', false)
void commandStore.execute('Comfy.ContactSupport')
emit('close')
}
},
{
key: 'help',
type: 'item',
icon: 'icon-[lucide--message-circle-question]',
label: t('helpCenter.help'),
action: () => {
trackResourceClick('help_feedback', false)
void commandStore.execute('Comfy.ContactSupport')
emit('close')
}
},
{
key: 'docs',
type: 'item',
icon: 'pi pi-book',
icon: 'icon-[lucide--book-open]',
label: t('helpCenter.docs'),
showExternalIcon: true,
action: () => {
trackResourceClick('docs', true)
const path = isCloud ? '/get_started/cloud' : '/'
@@ -290,6 +330,7 @@ const menuItems = computed<MenuItem[]>(() => {
type: 'item',
icon: 'pi pi-discord',
label: 'Discord',
showExternalIcon: true,
action: () => {
trackResourceClick('discord', true)
openExternalLink(staticUrls.discord)
@@ -299,24 +340,14 @@ const menuItems = computed<MenuItem[]>(() => {
{
key: 'github',
type: 'item',
icon: 'pi pi-github',
icon: 'icon-[lucide--github]',
label: t('helpCenter.github'),
showExternalIcon: true,
action: () => {
trackResourceClick('github', true)
openExternalLink(staticUrls.github)
emit('close')
}
},
{
key: 'help',
type: 'item',
icon: 'pi pi-question-circle',
label: t('helpCenter.helpFeedback'),
action: () => {
trackResourceClick('help_feedback', false)
void commandStore.execute('Comfy.ContactSupport')
emit('close')
}
}
]
@@ -438,32 +469,22 @@ const formatReleaseDate = (dateString?: string): string => {
const diffTime = Math.abs(now.getTime() - date.getTime())
const timeUnits = [
{ unit: TIME_UNITS.YEAR, suffix: 'y' },
{ unit: TIME_UNITS.MONTH, suffix: 'mo' },
{ unit: TIME_UNITS.WEEK, suffix: 'w' },
{ unit: TIME_UNITS.DAY, suffix: 'd' },
{ unit: TIME_UNITS.HOUR, suffix: 'h' },
{ unit: TIME_UNITS.MINUTE, suffix: 'min' }
{ unit: TIME_UNITS.YEAR, key: 'yearsAgo' },
{ unit: TIME_UNITS.MONTH, key: 'monthsAgo' },
{ unit: TIME_UNITS.WEEK, key: 'weeksAgo' },
{ unit: TIME_UNITS.DAY, key: 'daysAgo' },
{ unit: TIME_UNITS.HOUR, key: 'hoursAgo' },
{ unit: TIME_UNITS.MINUTE, key: 'minutesAgo' }
]
for (const { unit, suffix } of timeUnits) {
for (const { unit, key } of timeUnits) {
const value = Math.floor(diffTime / unit)
if (value > 0) {
return `${value}${suffix} ago`
return t(`g.relativeTime.${key}`, { count: value })
}
}
return 'now'
}
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
// Hide update buttons in cloud distribution
if (isCloud) return false
return (
releaseStore.shouldShowUpdateButton &&
release === releaseStore.recentReleases[0]
)
return t('g.relativeTime.now')
}
// Event Handlers
@@ -533,14 +554,6 @@ const onReleaseClick = (release: ReleaseNote): void => {
emit('close')
}
const onUpdate = (_: ReleaseNote): void => {
trackResourceClick('docs', true)
openExternalLink(
buildDocsUrl('/installation/update_comfyui', { includeLocale: true })
)
emit('close')
}
// Lifecycle
onMounted(async () => {
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
@@ -557,38 +570,37 @@ onBeforeUnmount(() => {
<style scoped>
.help-center-menu {
width: 380px;
width: 256px;
max-height: 500px;
overflow-y: auto;
background: var(--p-content-background);
border-radius: 12px;
box-shadow: 0 8px 32px rgb(0 0 0 / 0.15);
border: 1px solid var(--p-content-border-color);
backdrop-filter: blur(8px);
background: var(--interface-menu-surface);
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 0.1);
border: 1px solid var(--interface-menu-stroke);
padding: 12px 8px;
position: relative;
}
.help-menu-section {
padding: 0.5rem 0;
border-bottom: 1px solid var(--p-content-border-color);
}
.help-menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem 1rem;
height: 32px;
min-height: 24px;
padding: 8px;
gap: 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.9rem;
color: inherit;
color: var(--text-primary);
text-align: left;
}
.help-menu-item:hover {
background-color: #007aff26;
background-color: var(--interface-menu-component-surface-hovered);
}
.help-menu-item:focus,
@@ -599,16 +611,16 @@ onBeforeUnmount(() => {
.help-menu-icon-container {
position: relative;
margin-right: 0.75rem;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.help-menu-icon {
margin-right: 0.75rem;
font-size: 1rem;
color: var(--p-text-muted-color);
width: 16px;
height: 16px;
font-size: 16px;
color: var(--text-primary);
display: flex;
justify-content: center;
align-items: center;
@@ -616,7 +628,9 @@ onBeforeUnmount(() => {
}
.help-menu-icon svg {
color: var(--p-text-muted-color);
width: 16px;
height: 16px;
color: var(--text-primary);
}
.menu-red-dot {
@@ -639,16 +653,14 @@ onBeforeUnmount(() => {
justify-content: space-between;
}
.whats-new-section {
padding: 0.5rem 0;
}
.section-description {
font-size: 0.8rem;
font-weight: 600;
color: var(--p-text-muted-color);
margin: 0 0 0.5rem;
padding: 0 1rem;
color: var(--text-secondary);
font-family: var(--font-inter);
font-size: 12px;
font-style: normal;
font-weight: 700;
line-height: normal;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -661,7 +673,7 @@ onBeforeUnmount(() => {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 4px;
min-width: 0;
}
@@ -669,12 +681,22 @@ onBeforeUnmount(() => {
font-size: 0.9rem;
line-height: 1.2;
font-weight: 500;
color: var(--text-primary);
}
.release-date {
height: 16px;
font-size: 0.75rem;
color: var(--p-text-muted-color);
color: var(--text-secondary);
font-family: var(--font-inter);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.release-date .hover-state {
@@ -691,35 +713,31 @@ onBeforeUnmount(() => {
display: inline;
}
.update-button {
margin-left: 0.5rem;
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
flex-shrink: 0;
}
/* Submenu Styles */
.more-submenu {
width: 210px;
padding: 0.5rem 0;
background: var(--p-content-background);
border-radius: 12px;
border: 1px solid var(--p-content-border-color);
box-shadow: 0 8px 32px rgb(0 0 0 / 0.15);
padding: 12px 8px;
background: var(--interface-menu-surface);
border-radius: 8px;
border: 1px solid var(--interface-menu-stroke);
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 0.1);
overflow: hidden;
transition: opacity 0.15s ease-out;
}
.submenu-item {
padding: 0.75rem 1rem;
color: inherit;
padding: 8px;
height: 32px;
min-height: 24px;
border-radius: 4px;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: inherit;
line-height: inherit;
}
.submenu-item:hover {
background-color: #007aff26;
background-color: var(--interface-menu-component-surface-hovered);
}
.submenu-item:focus,
@@ -730,8 +748,8 @@ onBeforeUnmount(() => {
.submenu-divider {
height: 1px;
background: #3e3e3e;
margin: 0.5rem 0;
background: var(--interface-menu-stroke);
margin: 4px 0;
}
/* Scrollbar Styling */
@@ -744,12 +762,12 @@ onBeforeUnmount(() => {
}
.help-center-menu::-webkit-scrollbar-thumb {
background: var(--p-content-border-color);
background: var(--interface-menu-stroke);
border-radius: 3px;
}
.help-center-menu::-webkit-scrollbar-thumb:hover {
background: var(--p-text-muted-color);
background: var(--text-secondary);
}
/* Reduced Motion */

View File

@@ -75,6 +75,7 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -263,11 +264,21 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
const interruptAll = wrapWithErrorHandlingAsync(async () => {
const tasks = queueStore.runningTasks
await Promise.all(
tasks
.filter((task) => task.promptId != null)
.map((task) => api.interrupt(task.promptId))
)
const promptIds = tasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
if (!promptIds.length) return
// Cloud backend supports cancelling specific jobs via /queue delete,
// while /interrupt always targets the "first" job. Use the targeted API
// on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) {
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
return
}
await Promise.all(promptIds.map((id) => api.interrupt(id)))
})
const showClearHistoryDialog = () => {

View File

@@ -182,7 +182,7 @@ function handleTitleCancel() {
<Tab
v-for="tab in tabs"
:key="tab.value"
class="text-sm py-1 px-2"
class="text-sm py-1 px-2 font-inter"
:value="tab.value"
>
{{ tab.label() }}

View File

@@ -1,36 +0,0 @@
<template>
<div
class="flex h-full flex-col bg-interface-panel-surface"
:class="props.class"
>
<div>
<div
v-if="slots.top"
class="flex min-h-12 items-center border-b border-interface-stroke px-4 py-2"
>
<slot name="top" />
</div>
<div v-if="slots.header" class="px-4 pb-4">
<slot name="header" />
</div>
</div>
<!-- min-h-0 to force scrollpanel to grow -->
<ScrollPanel class="min-h-0 grow">
<slot name="body" />
</ScrollPanel>
<div v-if="slots.footer">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { useSlots } from 'vue'
const props = defineProps<{
class?: string
}>()
const slots = useSlots()
</script>

View File

@@ -1,19 +1,21 @@
<template>
<AssetsSidebarTemplate>
<template #top>
<span v-if="!isInFolderView" class="font-bold">
{{ $t('sideToolbar.mediaAssets.title') }}
</span>
<div v-else class="flex w-full items-center justify-between gap-2">
<SidebarTabTemplate
:title="isInFolderView ? '' : $t('sideToolbar.mediaAssets.title')"
>
<template #alt-title>
<div
v-if="isInFolderView"
class="flex w-full items-center justify-between gap-2"
>
<div class="flex items-center gap-2">
<span class="font-bold">{{ $t('Job ID') }}:</span>
<span class="font-bold">{{ $t('assetBrowser.jobId') }}:</span>
<span class="text-sm">{{ folderPromptId?.substring(0, 8) }}</span>
<button
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
role="button"
@click="copyJobId"
>
<i class="mb-1 icon-[lucide--copy] text-sm"></i>
<i class="icon-[lucide--copy] text-sm"></i>
</button>
</div>
<div>
@@ -23,7 +25,7 @@
</template>
<template #header>
<!-- Job Detail View Header -->
<div v-if="isInFolderView" class="pt-4 pb-2">
<div v-if="isInFolderView" class="px-2 2xl:px-4">
<IconTextButton
:label="$t('sideToolbar.backToAssets')"
type="secondary"
@@ -35,15 +37,20 @@
</IconTextButton>
</div>
<!-- Normal Tab View -->
<TabList v-else v-model="activeTab" class="pt-4 pb-1">
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
<TabList v-else v-model="activeTab" class="font-inter px-2 2xl:px-4">
<Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated')
}}</Tab>
<Tab class="font-inter" value="input">{{
$t('sideToolbar.labels.imported')
}}</Tab>
</TabList>
<!-- Filter Bar -->
<MediaAssetFilterBar
v-model:search-query="searchQuery"
v-model:sort-by="sortBy"
v-model:media-type-filters="mediaTypeFilters"
class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
</template>
@@ -158,7 +165,7 @@
</div>
</div>
</template>
</AssetsSidebarTemplate>
</SidebarTabTemplate>
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
@@ -177,6 +184,7 @@ import TextButton from '@/components/button/TextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
@@ -194,8 +202,6 @@ import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)

View File

@@ -1,8 +1,5 @@
<template>
<SidebarTabTemplate
:title="$t('sideToolbar.modelLibrary')"
class="bg-(--p-tree-background)"
>
<SidebarTabTemplate :title="$t('sideToolbar.modelLibrary')">
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.refresh')"

View File

@@ -3,7 +3,6 @@
<SidebarTabTemplate
v-if="!isHelpOpen"
:title="$t('sideToolbar.nodeLibrary')"
class="bg-(--p-tree-background)"
>
<template #tool-buttons>
<Button

View File

@@ -3,12 +3,15 @@
class="comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col"
:class="props.class"
>
<div class="comfy-vue-side-bar-header">
<Toolbar class="min-h-8 rounded-none border-x-0 border-t-0 px-2 py-1">
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
class="min-h-15.5 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
>
<template #start>
<span class="truncate text-xs 2xl:text-sm" :title="props.title">
{{ props.title.toUpperCase() }}
<span class="truncate font-bold" :title="props.title">
{{ props.title }}
</span>
<slot name="alt-title" />
</template>
<template #end>
<div

View File

@@ -1,7 +1,7 @@
<template>
<SidebarTabTemplate
:title="$t('sideToolbar.workflows')"
class="workflows-sidebar-tab bg-(--p-tree-background)"
class="workflows-sidebar-tab"
>
<template #tool-buttons>
<Button

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex h-full flex-col overflow-auto bg-(--p-tree-background)">
<div class="flex h-full flex-col overflow-auto">
<div
class="flex items-center border-b border-(--p-divider-color) px-3 py-2"
>

View File

@@ -1,10 +1,10 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopover from './CurrentUserPopover.vue'
@@ -74,7 +74,9 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' })
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: { amount_micros: 100_000 }, // 100,000 cents = ~211,000 credits
isFetchingBalance: false
}))
}))
@@ -107,6 +109,39 @@ vi.mock('@/components/common/UserCredit.vue', () => ({
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`)
}))
}))
// Mock useFeatureFlags
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: {
subscriptionTiersEnabled: true
}
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
name: 'SubscribeButtonMock',
@@ -145,27 +180,37 @@ describe('CurrentUserPopover', () => {
expect(wrapper.text()).toContain('test@example.com')
})
it('renders logout button with correct props', () => {
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 100_000,
locale: 'en',
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
// Verify the formatted credit string (1000) is rendered in the DOM
expect(wrapper.text()).toContain('1000')
})
it('opens user settings and emits close event when settings button is clicked', async () => {
it('renders logout menu item with correct text', () => {
const wrapper = mountComponent()
// Find all buttons and get the settings button (third button)
const buttons = wrapper.findAllComponents(Button)
const settingsButton = buttons[2]
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
expect(wrapper.text()).toContain('Log Out')
})
// Click the settings button
await settingsButton.trigger('click')
it('opens user settings and emits close event when settings item is clicked', async () => {
const wrapper = mountComponent()
const settingsItem = wrapper.find('[data-testid="user-settings-menu-item"]')
expect(settingsItem.exists()).toBe(true)
await settingsItem.trigger('click')
// Verify showSettingsDialog was called with 'user'
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
@@ -175,15 +220,13 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('calls logout function and emits close event when logout button is clicked', async () => {
it('calls logout function and emits close event when logout item is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (last button)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
// Click the logout button
await logoutButton.trigger('click')
await logoutItem.trigger('click')
// Verify handleSignOut was called
expect(mockHandleSignOut).toHaveBeenCalled()
@@ -193,15 +236,15 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
it('opens API pricing docs and emits close event when partner nodes item is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the Partner Nodes info button (first one)
const buttons = wrapper.findAllComponents(Button)
const partnerNodesButton = buttons[0]
const partnerNodesItem = wrapper.find(
'[data-testid="partner-nodes-menu-item"]'
)
expect(partnerNodesItem.exists()).toBe(true)
// Click the Partner Nodes button
await partnerNodesButton.trigger('click')
await partnerNodesItem.trigger('click')
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
@@ -217,11 +260,9 @@ describe('CurrentUserPopover', () => {
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the top-up button (second one)
const buttons = wrapper.findAllComponents(Button)
const topUpButton = buttons[1]
const topUpButton = wrapper.find('[data-testid="add-credits-button"]')
expect(topUpButton.exists()).toBe(true)
// Click the top-up button
await topUpButton.trigger('click')
// Verify showTopUpCreditsDialog was called

View File

@@ -1,112 +1,141 @@
<!-- A popover that shows current user information and actions -->
<template>
<div class="current-user-popover w-72">
<div
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->
<div class="p-3">
<div class="flex flex-col items-center">
<UserAvatar
class="mb-3"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<div class="flex flex-col items-center px-0 py-3 mb-4">
<UserAvatar
class="mb-1"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-lg font-semibold">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
</div>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
</div>
<div v-if="isActiveSubscription" class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<UserCredit text-class="text-2xl" />
<Button
:label="$t('subscription.partnerNodesCredits')"
severity="secondary"
text
size="small"
class="pl-6 p-0 h-auto justify-start"
:pt="{
root: {
class: 'hover:bg-transparent active:bg-transparent'
}
}"
@click="handleOpenPartnerNodesInfo"
/>
</div>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span class="text-base font-normal text-base-foreground flex-1">{{
formattedBalance
}}</span>
<Button
:label="$t('credits.topUp.topUp')"
:label="$t('subscription.addCredits')"
severity="secondary"
size="small"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
/>
</div>
<SubscribeButton
v-else
class="mx-4"
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
<Divider class="my-2" />
<!-- Credits info row -->
<div
v-if="flags.subscriptionTiersEnabled && isActiveSubscription"
class="flex items-center gap-2 px-4 py-0"
>
<i
v-tooltip="{
value: $t('credits.unified.tooltip'),
showDelay: 300,
hideDelay: 300
}"
class="icon-[lucide--circle-help] cursor-help text-xs text-muted-foreground"
/>
<span class="text-sm text-muted-foreground">{{
$t('credits.unified.message')
}}</span>
</div>
<Button
class="justify-start"
:label="$t('userSettings.title')"
icon="pi pi-cog"
text
fluid
severity="secondary"
@click="handleOpenUserSettings"
/>
<Divider class="my-2 mx-0" />
<Button
<div
v-if="isActiveSubscription"
class="justify-start"
:label="$t(planSettingsLabel)"
icon="pi pi-receipt"
text
fluid
severity="secondary"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
>
<i class="icon-[lucide--tag] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('subscription.partnerNodesCredits')
}}</span>
</div>
<div
v-if="isActiveSubscription"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="plan-credits-menu-item"
@click="handleOpenPlanAndCreditsSettings"
/>
>
<i class="icon-[lucide--receipt-text] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t(planSettingsLabel)
}}</span>
</div>
<Divider class="my-2" />
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="user-settings-menu-item"
@click="handleOpenUserSettings"
>
<i class="icon-[lucide--settings-2] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('userSettings.title')
}}</span>
</div>
<Button
class="justify-start"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
text
fluid
severity="secondary"
<Divider class="my-2 mx-0" />
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="logout-menu-item"
@click="handleLogout"
/>
>
<i class="icon-[lucide--log-out] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('auth.signOut.signOut')
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const emit = defineEmits<{
close: []
@@ -121,8 +150,24 @@ const planSettingsLabel = isCloud
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { flags } = useFeatureFlags()
const { locale } = useI18n()
const formattedBalance = computed(() => {
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
})
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')

View File

@@ -69,6 +69,7 @@ export interface VueNodeData {
}
color?: string
bgcolor?: string
shape?: number
}
export interface GraphNodeManager {
@@ -234,7 +235,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined
bgcolor: node.bgcolor || undefined,
shape: node.shape
}
}
@@ -571,6 +573,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
? propertyEvent.newValue
: undefined
})
break
case 'shape':
vueNodeData.set(nodeId, {
...currentData,
shape:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: undefined
})
}
}
},

View File

@@ -1,10 +1,16 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
const componentIconSvg = new Image()
componentIconSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
export const usePriceBadge = () => {
const { flags } = useFeatureFlags()
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
@@ -33,34 +39,54 @@ export const usePriceBadge = () => {
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
return (
(typeof badge === 'function' ? badge() : badge).icon?.unicode === '\ue96b'
)
const badgeInstance = typeof badge === 'function' ? badge() : badge
if (flags.subscriptionTiersEnabled) {
return badgeInstance.icon?.image === componentIconSvg
} else {
return badgeInstance.icon?.unicode === '\ue96b'
}
}
const colorPaletteStore = useColorPaletteStore()
function getCreditsBadge(price: string): LGraphBadge {
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
if (flags.subscriptionTiersEnabled) {
return new LGraphBadge({
text: price,
iconOptions: {
image: componentIconSvg,
size: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
} else {
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
}
return {
getCreditsBadge,

View File

@@ -1,7 +1,7 @@
import { computed } from 'vue'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/i18n'
/**
* Composable for building docs.comfy.org URLs with automatic locale and platform detection
@@ -23,7 +23,7 @@ import { useI18n } from 'vue-i18n'
* ```
*/
export function useExternalLink() {
const { locale } = useI18n()
const locale = computed(() => String(i18n.global.locale.value))
const isChinese = computed(() => {
return locale.value === 'zh' || locale.value === 'zh-TW'

View File

@@ -12,7 +12,8 @@ export enum ServerFeatureFlag {
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled'
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
SUBSCRIPTION_TIERS_ENABLED = 'subscription_tiers_enabled'
}
/**
@@ -55,6 +56,16 @@ export function useFeatureFlags() {
remoteConfig.value.private_models_enabled ??
api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false)
)
},
get subscriptionTiersEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.subscription_tiers_enabled ??
api.getServerFeature(
ServerFeatureFlag.SUBSCRIPTION_TIERS_ENABLED,
true // Default to true (new design)
)
)
}
})

View File

@@ -0,0 +1,34 @@
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
export const STRIPE_PRICING_TABLE_SCRIPT_SRC =
'https://js.stripe.com/v3/pricing-table.js'
interface StripePricingTableConfig {
publishableKey: string
pricingTableId: string
}
function getEnvValue(
key: 'VITE_STRIPE_PUBLISHABLE_KEY' | 'VITE_STRIPE_PRICING_TABLE_ID'
) {
return import.meta.env[key]
}
export function getStripePricingTableConfig(): StripePricingTableConfig {
const publishableKey =
remoteConfig.value.stripe_publishable_key ||
window.__CONFIG__?.stripe_publishable_key ||
getEnvValue('VITE_STRIPE_PUBLISHABLE_KEY') ||
''
const pricingTableId =
remoteConfig.value.stripe_pricing_table_id ||
window.__CONFIG__?.stripe_pricing_table_id ||
getEnvValue('VITE_STRIPE_PRICING_TABLE_ID') ||
''
return {
publishableKey,
pricingTableId
}
}

View File

@@ -14,13 +14,15 @@ app.registerExtension({
static collapsable: boolean
static title_mode: number
override color = LGraphCanvas.node_colors.yellow.color
override bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
override isVirtualNode: boolean
constructor(title: string) {
super(title)
this.color = LGraphCanvas.node_colors.yellow.color
this.bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
if (!this.properties) {
this.properties = { text: '' }
}
@@ -53,12 +55,14 @@ app.registerExtension({
class MarkdownNoteNode extends LGraphNode {
static override title = 'Markdown Note'
override color = LGraphCanvas.node_colors.yellow.color
override bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
constructor(title: string) {
super(title)
this.color = LGraphCanvas.node_colors.yellow.color
this.bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
if (!this.properties) {
this.properties = { text: '' }
}

View File

@@ -66,8 +66,12 @@ export class LGraphBadge {
const { font } = ctx
let iconWidth = 0
if (this.icon) {
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
if (this.icon.image) {
iconWidth = this.icon.size + this.padding
} else if (this.icon.unicode) {
ctx.font = `${this.icon.fontSize}px '${this.icon.fontFamily}'`
iconWidth = ctx.measureText(this.icon.unicode).width + this.padding
}
}
ctx.font = `${this.fontSize}px sans-serif`
const textWidth = this.text ? ctx.measureText(this.text).width : 0
@@ -104,7 +108,8 @@ export class LGraphBadge {
// Draw icon if present
if (this.icon) {
this.icon.draw(ctx, drawX, centerY)
drawX += this.icon.fontSize + this.padding / 2 + 4
const iconWidth = this.icon.image ? this.icon.size : this.icon.fontSize
drawX += iconWidth + this.padding / 2 + 4
}
// Draw badge text

View File

@@ -1,20 +1,24 @@
export interface LGraphIconOptions {
unicode: string
unicode?: string
fontFamily?: string
image?: HTMLImageElement
color?: string
bgColor?: string
fontSize?: number
size?: number
circlePadding?: number
xOffset?: number
yOffset?: number
}
export class LGraphIcon {
unicode: string
unicode?: string
fontFamily: string
image?: HTMLImageElement
color: string
bgColor?: string
fontSize: number
size: number
circlePadding: number
xOffset: number
yOffset: number
@@ -22,18 +26,22 @@ export class LGraphIcon {
constructor({
unicode,
fontFamily = 'PrimeIcons',
image,
color = '#e6c200',
bgColor,
fontSize = 16,
size,
circlePadding = 2,
xOffset = 0,
yOffset = 0
}: LGraphIconOptions) {
this.unicode = unicode
this.fontFamily = fontFamily
this.image = image
this.color = color
this.bgColor = bgColor
this.fontSize = fontSize
this.size = size ?? fontSize
this.circlePadding = circlePadding
this.xOffset = xOffset
this.yOffset = yOffset
@@ -43,26 +51,44 @@ export class LGraphIcon {
x += this.xOffset
y += this.yOffset
const { font, textBaseline, textAlign, fillStyle } = ctx
if (this.image) {
const iconSize = this.size
const iconRadius = iconSize / 2 + this.circlePadding
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
const iconRadius = this.fontSize / 2 + this.circlePadding
// Draw icon background circle if bgColor is set
if (this.bgColor) {
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
if (this.bgColor) {
const { fillStyle } = ctx
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
ctx.fillStyle = fillStyle
}
const imageX = x + this.circlePadding
const imageY = y - iconSize / 2
ctx.drawImage(this.image, imageX, imageY, iconSize, iconSize)
} else if (this.unicode) {
const { font, textBaseline, textAlign, fillStyle } = ctx
ctx.font = `${this.fontSize}px '${this.fontFamily}'`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
const iconRadius = this.fontSize / 2 + this.circlePadding
if (this.bgColor) {
ctx.beginPath()
ctx.arc(x + iconRadius, y, iconRadius, 0, 2 * Math.PI)
ctx.fillStyle = this.bgColor
ctx.fill()
}
ctx.fillStyle = this.color
ctx.fillText(this.unicode, x + iconRadius, y)
ctx.font = font
ctx.textBaseline = textBaseline
ctx.textAlign = textAlign
ctx.fillStyle = fillStyle
}
// Draw icon
ctx.fillStyle = this.color
ctx.fillText(this.unicode, x + iconRadius, y)
ctx.font = font
ctx.textBaseline = textBaseline
ctx.textAlign = textAlign
ctx.fillStyle = fillStyle
}
}

View File

@@ -495,6 +495,7 @@ export class LGraphNode
}
set shape(v: RenderShape | 'default' | 'box' | 'round' | 'circle' | 'card') {
const oldValue = this._shape
switch (v) {
case 'default':
this._shape = undefined
@@ -514,6 +515,14 @@ export class LGraphNode
default:
this._shape = v
}
if (oldValue !== this._shape) {
this.graph?.trigger('node:property:changed', {
nodeId: this.id,
property: 'shape',
oldValue,
newValue: this._shape
})
}
}
/**
@@ -851,13 +860,12 @@ export class LGraphNode
}
if (info.widgets_values) {
const widgetsWithValue = this.widgets
.values()
.filter((w) => w.serialize !== false)
.filter((_w, idx) => idx < info.widgets_values!.length)
widgetsWithValue.forEach(
(widget, i) => (widget.value = info.widgets_values![i])
)
let i = 0
for (const widget of this.widgets ?? []) {
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
}
}
}

View File

@@ -761,7 +761,6 @@
"desktopUserGuide": "دليل مستخدم سطح المكتب",
"docs": "الوثائق",
"github": "GitHub",
"helpFeedback": "المساعدة والتعليقات",
"loadingReleases": "جارٍ تحميل الإصدارات...",
"managerExtension": "المدير الموسع",
"more": "المزيد...",

View File

@@ -1,4 +1,40 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Check for Updates"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Open Custom Nodes Folder"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Open Inputs Folder"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Open Logs Folder"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "Open extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Open Models Folder"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "Open Outputs Folder"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Open DevTools"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Desktop User Guide"
},
"Comfy-Desktop_Quit": {
"label": "Quit"
},
"Comfy-Desktop_Reinstall": {
"label": "Reinstall"
},
"Comfy-Desktop_Restart": {
"label": "Restart"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Open 3D Viewer (Beta) for Selected Node"
},

View File

@@ -97,6 +97,7 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
"or": "or",
"pressKeysForNewBinding": "Press keys for new binding",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",
@@ -199,6 +200,15 @@
"copy": "Copy",
"copyJobId": "Copy Job ID",
"copied": "Copied",
"relativeTime": {
"now": "now",
"yearsAgo": "{count}y ago",
"monthsAgo": "{count}mo ago",
"weeksAgo": "{count}w ago",
"daysAgo": "{count}d ago",
"hoursAgo": "{count}h ago",
"minutesAgo": "{count}min ago"
},
"jobIdCopied": "Job ID copied to clipboard",
"failedToCopyJobId": "Failed to copy job ID",
"imageUrl": "Image URL",
@@ -465,6 +475,7 @@
"notSupported": {
"title": "Your device is not supported",
"message": "Only following devices are supported:",
"illustrationAlt": "Sad girl illustration",
"learnMore": "Learn More",
"reportIssue": "Report Issue",
"supportedDevices": {
@@ -746,9 +757,10 @@
}
},
"helpCenter": {
"feedback": "Give Feedback",
"docs": "Docs",
"github": "Github",
"helpFeedback": "Help & Feedback",
"help": "Help & Support",
"managerExtension": "Manager Extension",
"more": "More...",
"whatsNew": "What's New?",
@@ -762,10 +774,11 @@
"reinstall": "Re-Install"
},
"releaseToast": {
"newVersionAvailable": "New Version Available!",
"whatsNew": "What's New?",
"newVersionAvailable": "New update is out!",
"whatsNew": "See what's new",
"skip": "Skip",
"update": "Update"
"update": "Update",
"description": "Check out the latest improvements and features in this update."
},
"menu": {
"hideMenu": "Hide Menu",
@@ -1041,6 +1054,18 @@
"Edit": "Edit",
"View": "View",
"Help": "Help",
"Check for Updates": "Check for Updates",
"Open Custom Nodes Folder": "Open Custom Nodes Folder",
"Open Inputs Folder": "Open Inputs Folder",
"Open Logs Folder": "Open Logs Folder",
"Open extra_model_paths_yaml": "Open extra_model_paths.yaml",
"Open Models Folder": "Open Models Folder",
"Open Outputs Folder": "Open Outputs Folder",
"Open DevTools": "Open DevTools",
"Desktop User Guide": "Desktop User Guide",
"Quit": "Quit",
"Reinstall": "Reinstall",
"Restart": "Restart",
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
"Experimental: Browse Model Assets": "Experimental: Browse Model Assets",
"Browse Templates": "Browse Templates",
@@ -1833,15 +1858,34 @@
"maxAmount": "(Max. $1,000 USD)",
"buyNow": "Buy now",
"seeDetails": "See details",
"topUp": "Top Up"
"topUp": "Top Up",
"addMoreCredits": "Add more credits",
"addMoreCreditsToRun": "Add more credits to run",
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
"creditsDescription": "Credits are used to run workflows or partner nodes.",
"howManyCredits": "How many credits would you like to add?",
"videosEstimate": "~{count} videos*",
"templateNote": "*Generated with Wan Fun Control template",
"buy": "Buy",
"purchaseSuccess": "Purchase Successful",
"purchaseSuccessDetail": "Successfully purchased {credits} credits for {amount}",
"purchaseError": "Purchase Failed",
"purchaseErrorDetail": "Failed to purchase credits: {error}",
"unknownError": "An unknown error occurred"
},
"creditsAvailable": "Credits available",
"refreshes": "Refreshes {date}",
"eventType": "Event Type",
"details": "Details",
"time": "Time",
"additionalInfo": "Additional Info",
"model": "Model",
"added": "Added",
"accountInitialized": "Account initialized"
"accountInitialized": "Account initialized",
"unified": {
"message": "Credits have been unified",
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
}
},
"subscription": {
"title": "Subscription",
@@ -1849,7 +1893,7 @@
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "USD / month",
"perMonth": "/ month",
"renewsDate": "Renews {date}",
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
"expiresDate": "Expires {date}",
@@ -1864,6 +1908,10 @@
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsRemainingThisMonth": "Credits remaining for this month",
"creditsYouveAdded": "Credits you've added",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
@@ -1874,19 +1922,78 @@
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"tiers": {
"founder": {
"name": "Founder's Edition Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "5,460 monthly credits",
"maxDuration": "30 min max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever"
}
},
"standard": {
"name": "Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "4,200 monthly credits",
"maxDuration": "30 min max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever",
"customLoRAs": "Import your own LoRAs",
"videoEstimate": "164"
}
},
"creator": {
"name": "Creator",
"price": "35.00",
"benefits": {
"monthlyCredits": "7,400",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs"
}
},
"pro": {
"name": "Pro",
"price": "100.00",
"benefits": {
"monthlyCredits": "21,100 monthly credits",
"maxDuration": "1 hr max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever",
"customLoRAs": "Import your own LoRAs",
"videoEstimate": "821"
}
}
},
"required": {
"title": "Subscribe to",
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
"subscribe": "Subscribe"
},
"pricingTable": {
"description": "Access cloud-powered ComfyUI workflows with straightforward, usage-based pricing.",
"loading": "Loading pricing options...",
"loadError": "We couldn't load the pricing table. Please refresh and try again.",
"missingConfig": "Stripe pricing table configuration missing. Provide the publishable key and pricing table ID via remote config or .env."
},
"subscribeToRun": "Subscribe",
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"partnerNodesCredits": "Partner Nodes pricing table"
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"partnerNodesCredits": "Partner nodes pricing"
},
"userSettings": {
"title": "User Settings",
"title": "My Account Settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",
@@ -1927,6 +2034,7 @@
},
"whatsNewPopup": {
"learnMore": "Learn more",
"later": "Later",
"noReleaseNotes": "No release notes available."
},
"breadcrumbsMenu": {
@@ -2082,71 +2190,76 @@
"cloudSurvey_steps_industry": "What's your primary industry?",
"cloudSurvey_steps_making": "What do you plan on making?",
"assetBrowser": {
"assets": "Assets",
"allCategory": "All {category}",
"allModels": "All Models",
"assetCollection": "Asset collection",
"checkpoints": "Checkpoints",
"assets": "Assets",
"baseModels": "Base models",
"browseAssets": "Browse Assets",
"noAssetsFound": "No assets found",
"tryAdjustingFilters": "Try adjusting your search or filters",
"loadingModels": "Loading {type}...",
"checkpoints": "Checkpoints",
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor</a>",
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
"civitaiLinkPlaceholder": "Paste link here",
"confirmModelDetails": "Confirm Model Details",
"connectionError": "Please check your connection and try again",
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
"errorModelTypeNotSupported": "This model type is not supported",
"errorUnknown": "An unexpected error occurred",
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
"errorUploadFailed": "Failed to import asset. Please try again.",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"fileFormats": "File formats",
"fileName": "File Name",
"fileSize": "File Size",
"filterBy": "Filter by",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"jobId": "Job ID",
"loadingModels": "Loading {type}...",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
"modelTypeSelectorLabel": "What type of model is this?",
"modelTypeSelectorPlaceholder": "Select model type",
"modelUploaded": "Model imported! 🎉",
"noAssetsFound": "No assets found",
"noModelsInFolder": "No {type} available in this folder",
"uploadModel": "Import",
"uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"ownership": "Ownership",
"ownershipAll": "All",
"ownershipMyModels": "My models",
"ownershipPublicModels": "Public models",
"selectFrameworks": "Select Frameworks",
"selectModelType": "Select model type",
"selectProjects": "Select Projects",
"sortAZ": "A-Z",
"sortBy": "Sort by",
"sortPopular": "Popular",
"sortRecent": "Recent",
"sortZA": "Z-A",
"sortingType": "Sorting Type",
"tags": "Tags",
"tagsHelp": "Separate tags with commas",
"tagsPlaceholder": "e.g., models, checkpoint",
"tryAdjustingFilters": "Try adjusting your search or filters",
"unknown": "Unknown",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import",
"uploadFailed": "Import failed",
"uploadingModel": "Importing model...",
"uploadModel": "Import",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com</a> are supported at the moment",
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
"civitaiLinkPlaceholder": "Paste link here",
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor</a>",
"confirmModelDetails": "Confirm Model Details",
"fileName": "File Name",
"fileSize": "File Size",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
"tags": "Tags",
"tagsPlaceholder": "e.g., models, checkpoint",
"tagsHelp": "Separate tags with commas",
"upload": "Import",
"uploadingModel": "Importing model...",
"uploadSuccess": "Model imported successfully!",
"uploadFailed": "Import failed",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelHelpVideo": "Upload Model Help Video",
"uploadModelHowDoIFindThis": "How do I find this?",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelTypeSelectorLabel": "What type of model is this?",
"modelTypeSelectorPlaceholder": "Select model type",
"selectModelType": "Select model type",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"modelUploaded": "Model imported! 🎉",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"allModels": "All Models",
"allCategory": "All {category}",
"unknown": "Unknown",
"fileFormats": "File formats",
"baseModels": "Base models",
"filterBy": "Filter by",
"sortBy": "Sort by",
"sortAZ": "A-Z",
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular",
"selectFrameworks": "Select Frameworks",
"selectProjects": "Select Projects",
"sortingType": "Sorting Type",
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
"errorModelTypeNotSupported": "This model type is not supported",
"errorUnknown": "An unexpected error occurred",
"errorUploadFailed": "Failed to import asset. Please try again.",
"uploadSuccess": "Model imported successfully!",
"ariaLabel": {
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
@@ -2271,6 +2384,12 @@
"inputs": "INPUTS",
"inputsNone": "NO INPUTS",
"inputsNoneTooltip": "Node has no inputs",
"nodeState": "Node state"
"properties": "Properties",
"nodeState": "Node state",
"settings": "Settings"
},
"help": {
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}

View File

@@ -1,4 +1,30 @@
{
"Comfy-Desktop_AutoUpdate": {
"name": "Automatically check for updates"
},
"Comfy-Desktop_SendStatistics": {
"name": "Send anonymous usage metrics"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi Install Mirror",
"tooltip": "Default pip install mirror"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python Install Mirror",
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme."
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch Install Mirror",
"tooltip": "Pip install mirror for pytorch"
},
"Comfy-Desktop_WindowStyle": {
"name": "Window Style",
"tooltip": "Custom: Replace the system title bar with ComfyUI's Top menu",
"options": {
"default": "default",
"custom": "custom"
}
},
"Comfy_Canvas_BackgroundImage": {
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."

View File

@@ -761,7 +761,6 @@
"desktopUserGuide": "Guía de usuario de escritorio",
"docs": "Documentación",
"github": "Github",
"helpFeedback": "Ayuda y comentarios",
"loadingReleases": "Cargando versiones...",
"managerExtension": "Extensión del Administrador",
"more": "Más...",

View File

@@ -761,7 +761,6 @@
"desktopUserGuide": "Guide utilisateur de bureau",
"docs": "Docs",
"github": "Github",
"helpFeedback": "Aide & Retour",
"loadingReleases": "Chargement des versions...",
"managerExtension": "Manager Extension",
"more": "Plus...",

View File

@@ -761,7 +761,6 @@
"desktopUserGuide": "デスクトップユーザーガイド",
"docs": "ドキュメント",
"github": "Github",
"helpFeedback": "ヘルプとフィードバック",
"loadingReleases": "リリースを読み込み中...",
"managerExtension": "Manager Extension",
"more": "もっと見る...",

View File

@@ -761,7 +761,6 @@
"desktopUserGuide": "데스크톱 사용자 가이드",
"docs": "문서",
"github": "Github",
"helpFeedback": "도움말 및 피드백",
"loadingReleases": "릴리즈 불러오는 중...",
"managerExtension": "관리자 확장",
"more": "더보기...",

View File

@@ -761,7 +761,6 @@
"desktopUserGuide": "Руководство пользователя для Desktop",
"docs": "Документация",
"github": "Github",
"helpFeedback": "Помощь и обратная связь",
"loadingReleases": "Загрузка релизов...",
"managerExtension": "Расширение менеджера",
"more": "Ещё...",

View File

@@ -761,7 +761,6 @@
"desktopUserGuide": "Masaüstü Kullanıcı Kılavuzu",
"docs": "Belgeler",
"github": "Github",
"helpFeedback": "Yardım ve Geri Bildirim",
"loadingReleases": "Sürümler yükleniyor...",
"managerExtension": "Yönetici Uzantısı",
"more": "Daha Fazla...",

View File

@@ -761,7 +761,6 @@
"desktopUserGuide": "桌面版使用指南",
"docs": "文件",
"github": "Github",
"helpFeedback": "幫助與回饋",
"loadingReleases": "正在載入版本資訊…",
"managerExtension": "管理器擴充功能",
"more": "更多…",

View File

@@ -761,7 +761,6 @@
"desktopUserGuide": "桌面端用户指南",
"docs": "文档",
"github": "Github",
"helpFeedback": "帮助与反馈",
"loadingReleases": "加载发布信息...",
"managerExtension": "管理扩展",
"more": "更多...",

View File

@@ -48,6 +48,7 @@
<template #contentFilter>
<AssetFilterBar
:assets="categoryFilteredAssets"
:all-assets="fetchedAssets"
@filter-change="updateFilters"
/>
</template>

View File

@@ -26,6 +26,16 @@
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
/>
<SingleSelect
v-if="hasMutableAssets"
v-model="ownership"
:label="$t('assetBrowser.ownership')"
:options="ownershipOptions"
class="min-w-42"
data-component-id="asset-filter-ownership"
@update:model-value="handleFilterChange"
/>
</div>
<div class="flex items-center" data-component-id="asset-filter-bar-right">
@@ -46,21 +56,16 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { SelectOption } from '@/components/input/types'
import { t } from '@/i18n'
import type { OwnershipOption } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
export interface FilterState {
fileFormats: string[]
baseModels: string[]
sortBy: string
}
const SORT_OPTIONS = [
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
@@ -71,17 +76,37 @@ type SortOption = (typeof SORT_OPTIONS)[number]['value']
const sortOptions = [...SORT_OPTIONS]
const { assets = [] } = defineProps<{
const ownershipOptions = [
{ name: t('assetBrowser.ownershipAll'), value: 'all' },
{ name: t('assetBrowser.ownershipMyModels'), value: 'my-models' },
{ name: t('assetBrowser.ownershipPublicModels'), value: 'public-models' }
]
export interface FilterState {
fileFormats: string[]
baseModels: string[]
sortBy: string
ownership: OwnershipOption
}
const { assets = [], allAssets = [] } = defineProps<{
assets?: AssetItem[]
allAssets?: AssetItem[]
}>()
const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const sortBy = ref<SortOption>('recent')
const ownership = ref<OwnershipOption>('all')
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
const hasMutableAssets = computed(() => {
const assetsToCheck = allAssets.length ? allAssets : assets
return assetsToCheck.some((asset) => asset.is_immutable === false)
})
const emit = defineEmits<{
filterChange: [filters: FilterState]
}>()
@@ -90,7 +115,8 @@ function handleFilterChange() {
emit('filterChange', {
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
baseModels: baseModels.value.map((option: SelectOption) => option.value),
sortBy: sortBy.value
sortBy: sortBy.value,
ownership: ownership.value
})
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex gap-3 pt-2">
<div class="flex gap-3">
<SearchBox
:model-value="searchQuery"
:placeholder="$t('sideToolbar.searchAssets')"

View File

@@ -1,13 +1,22 @@
<template>
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
<!-- Model Info Section -->
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
</p>
<p class="mt-0 text-base-foreground rounded-lg">
{{ metadata?.filename || metadata?.name }}
</p>
<div
class="flex items-center gap-3 bg-secondary-background p-3 rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<p class="m-0 text-base-foreground">
{{ metadata?.filename || metadata?.name }}
</p>
</div>
</div>
<!-- Model Type Selection -->
@@ -40,7 +49,8 @@ import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
metadata: AssetMetadata | null
metadata?: AssetMetadata
previewImage?: string
}>()
const modelValue = defineModel<string | undefined>()

View File

@@ -14,6 +14,7 @@
v-else-if="currentStep === 2"
v-model="selectedModelType"
:metadata="wizardData.metadata"
:preview-image="wizardData.previewImage"
/>
<!-- Step 3: Upload Progress -->
@@ -23,6 +24,7 @@
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
:preview-image="wizardData.previewImage"
/>
<!-- Navigation Footer -->

View File

@@ -25,8 +25,14 @@
</p>
<div
class="flex flex-row items-start p-4 bg-modal-card-background rounded-lg"
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-base-foreground m-0">
{{ metadata?.filename || metadata?.name }}
@@ -63,7 +69,8 @@ import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
error?: string
metadata: AssetMetadata | null
modelType: string | undefined
metadata?: AssetMetadata
modelType?: string
previewImage?: string
}>()
</script>

View File

@@ -11,6 +11,8 @@ import {
getAssetDescription
} from '@/platform/assets/utils/assetMetadataUtils'
export type OwnershipOption = 'all' | 'my-models' | 'public-models'
function filterByCategory(category: string) {
return (asset: AssetItem) => {
return category === 'all' || asset.tags.includes(category)
@@ -35,6 +37,15 @@ function filterByBaseModels(models: string[]) {
}
}
function filterByOwnership(ownership: OwnershipOption) {
return (asset: AssetItem) => {
if (ownership === 'all') return true
if (ownership === 'my-models') return asset.is_immutable === false
if (ownership === 'public-models') return asset.is_immutable === true
return true
}
}
type AssetBadge = {
label: string
type: 'type' | 'base' | 'size'
@@ -65,7 +76,8 @@ export function useAssetBrowser(
const filters = ref<FilterState>({
sortBy: 'recent',
fileFormats: [],
baseModels: []
baseModels: [],
ownership: 'all'
})
// Transform API asset to display asset
@@ -176,6 +188,7 @@ export function useAssetBrowser(
const filtered = searchFiltered.value
.filter(filterByFileFormats(filters.value.fileFormats))
.filter(filterByBaseModels(filters.value.baseModels))
.filter(filterByOwnership(filters.value.ownership))
const sortedAssets = [...filtered]
sortedAssets.sort((a, b) => {

View File

@@ -9,9 +9,10 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
interface WizardData {
url: string
metadata: AssetMetadata | null
metadata?: AssetMetadata
name: string
tags: string[]
previewImage?: string
}
interface ModelTypeOption {
@@ -30,7 +31,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const wizardData = ref<WizardData>({
url: '',
metadata: null,
name: '',
tags: []
})
@@ -91,6 +91,9 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
// Pre-fill name from metadata
wizardData.value.name = metadata.filename || metadata.name || ''
// Store preview image if available
wizardData.value.previewImage = metadata.preview_image
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
@@ -134,6 +137,34 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
wizardData.value.metadata?.name ||
'model'
let previewId: string | undefined
// Upload preview image first if available
if (wizardData.value.previewImage) {
try {
const baseFilename = filename.split('.')[0]
// Extract extension from data URL MIME type
let extension = 'png'
const mimeMatch = wizardData.value.previewImage.match(
/^data:image\/([^;]+);/
)
if (mimeMatch) {
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
}
const previewAsset = await assetService.uploadAssetFromBase64({
data: wizardData.value.previewImage,
name: `${baseFilename}_preview.${extension}`,
tags: ['preview']
})
previewId = previewAsset.id
} catch (error) {
console.error('Failed to upload preview image:', error)
// Continue with model upload even if preview fails
}
}
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
@@ -142,7 +173,8 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
source: 'civitai',
source_url: wizardData.value.url,
model_type: selectedModelType.value
}
},
preview_id: previewId
})
uploadStatus.value = 'success'

View File

@@ -146,9 +146,15 @@ export function createAssetWithoutUserMetadata() {
return asset
}
export function createAssetWithSpecificExtension(extension: string) {
export function createAssetWithSpecificExtension(
extension: string,
isImmutable?: boolean
) {
const asset = createMockAssets(1)[0]
asset.name = `test-model.${extension}`
if (isImmutable !== undefined) {
asset.is_immutable = isImmutable
}
return asset
}

View File

@@ -54,6 +54,7 @@ const zAssetMetadata = z.object({
name: z.string().optional(),
tags: z.array(z.string()).optional(),
preview_url: z.string().optional(),
preview_image: z.string().optional(),
validation: zValidationResult.optional()
})

View File

@@ -392,6 +392,59 @@ function createAssetService() {
return await res.json()
}
/**
* Uploads an asset from base64 data
*
* @param params - Upload parameters
* @param params.data - Base64 data URL (e.g., "data:image/png;base64,...")
* @param params.name - Display name (determines extension)
* @param params.tags - Optional freeform tags
* @param params.user_metadata - Optional custom metadata object
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
* @throws Error if upload fails
*/
async function uploadAssetFromBase64(params: {
data: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
}): Promise<AssetItem & { created_new: boolean }> {
// Validate that data is a data URL
if (!params.data || !params.data.startsWith('data:')) {
throw new Error(
'Invalid data URL: expected a string starting with "data:"'
)
}
// Convert base64 data URL to Blob
const blob = await fetch(params.data).then((r) => r.blob())
// Create FormData and append the blob
const formData = new FormData()
formData.append('file', blob, params.name)
if (params.tags) {
formData.append('tags', JSON.stringify(params.tags))
}
if (params.user_metadata) {
formData.append('user_metadata', JSON.stringify(params.user_metadata))
}
const res = await api.fetchApi(ASSETS_ENDPOINT, {
method: 'POST',
body: formData
})
if (!res.ok) {
throw new Error(
`Failed to upload asset from base64: ${res.status} ${res.statusText}`
)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -402,7 +455,8 @@ function createAssetService() {
deleteAsset,
updateAsset,
getAssetMetadata,
uploadAssetFromUrl
uploadAssetFromUrl,
uploadAssetFromBase64
}
}

View File

@@ -0,0 +1,117 @@
<template>
<div
ref="tableContainer"
class="relative w-full rounded-[20px] border border-interface-stroke bg-interface-panel-background"
>
<div
v-if="!hasValidConfig"
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
data-testid="stripe-table-missing-config"
>
{{ $t('subscription.pricingTable.missingConfig') }}
</div>
<div
v-else-if="loadError"
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
data-testid="stripe-table-error"
>
{{ $t('subscription.pricingTable.loadError') }}
</div>
<div
v-else-if="!isReady"
class="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-text-secondary"
data-testid="stripe-table-loading"
>
{{ $t('subscription.pricingTable.loading') }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { getStripePricingTableConfig } from '@/config/stripePricingTableConfig'
import { useStripePricingTableLoader } from '@/platform/cloud/subscription/composables/useStripePricingTableLoader'
const props = defineProps<{
pricingTableId?: string
publishableKey?: string
}>()
const tableContainer = ref<HTMLDivElement | null>(null)
const isReady = ref(false)
const loadError = ref<string | null>(null)
const lastRenderedKey = ref('')
const stripeElement = ref<HTMLElement | null>(null)
const resolvedConfig = computed(() => {
const fallback = getStripePricingTableConfig()
return {
publishableKey: props.publishableKey || fallback.publishableKey,
pricingTableId: props.pricingTableId || fallback.pricingTableId
}
})
const hasValidConfig = computed(() => {
const { publishableKey, pricingTableId } = resolvedConfig.value
return Boolean(publishableKey && pricingTableId)
})
const { loadScript } = useStripePricingTableLoader()
const renderPricingTable = async () => {
if (!tableContainer.value) return
const { publishableKey, pricingTableId } = resolvedConfig.value
if (!publishableKey || !pricingTableId) {
return
}
const renderKey = `${publishableKey}:${pricingTableId}`
if (renderKey === lastRenderedKey.value && isReady.value) {
return
}
try {
await loadScript()
loadError.value = null
if (!tableContainer.value) {
return
}
if (stripeElement.value) {
stripeElement.value.remove()
stripeElement.value = null
}
const stripeTable = document.createElement('stripe-pricing-table')
stripeTable.setAttribute('publishable-key', publishableKey)
stripeTable.setAttribute('pricing-table-id', pricingTableId)
stripeTable.style.display = 'block'
stripeTable.style.width = '100%'
stripeTable.style.minHeight = '420px'
tableContainer.value.appendChild(stripeTable)
stripeElement.value = stripeTable
lastRenderedKey.value = renderKey
isReady.value = true
} catch (error) {
console.error('[StripePricingTable] Failed to load pricing table', error)
loadError.value = (error as Error).message
isReady.value = false
}
}
watch(
[resolvedConfig, () => tableContainer.value],
() => {
if (!hasValidConfig.value) return
if (!tableContainer.value) return
void renderPricingTable()
},
{ immediate: true }
)
onBeforeUnmount(() => {
stripeElement.value?.remove()
stripeElement.value = null
})
</script>

View File

@@ -24,8 +24,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -51,12 +52,18 @@ const emit = defineEmits<{
subscribed: []
}>()
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const { subscribe, isActiveSubscription, fetchStatus, showSubscriptionDialog } =
useSubscription()
const { flags } = useFeatureFlags()
const shouldUseStripePricing = computed(
() => isCloud && Boolean(flags.subscriptionTiersEnabled)
)
const telemetry = useTelemetry()
const isLoading = ref(false)
const isPolling = ref(false)
let pollInterval: number | null = null
const isAwaitingStripeSubscription = ref(false)
const POLL_INTERVAL_MS = 3000 // Poll every 3 seconds
const MAX_POLL_DURATION_MS = 5 * 60 * 1000 // Stop polling after 5 minutes
@@ -102,11 +109,27 @@ const stopPolling = () => {
isLoading.value = false
}
watch(
[isAwaitingStripeSubscription, isActiveSubscription],
([awaiting, isActive]) => {
if (shouldUseStripePricing.value && awaiting && isActive) {
emit('subscribed')
isAwaitingStripeSubscription.value = false
}
}
)
const handleSubscribe = async () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked')
}
if (shouldUseStripePricing.value) {
isAwaitingStripeSubscription.value = true
showSubscriptionDialog()
return
}
isLoading.value = true
try {
await subscribe()
@@ -120,5 +143,6 @@ const handleSubscribe = async () => {
onBeforeUnmount(() => {
stopPolling()
isAwaitingStripeSubscription.value = false
})
</script>

View File

@@ -1,42 +1,19 @@
<template>
<div class="flex flex-col items-start gap-1 self-stretch">
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<div class="flex flex-col items-start gap-0 self-stretch">
<div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit1') }}
</span>
</div>
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit2') }}
</span>
</div>
<Button
:label="$t('subscription.viewMoreDetails')"
text
icon="pi pi-external-link"
icon-pos="left"
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
:pt="{
icon: {
class: 'text-xs text-text-secondary'
},
label: {
class: 'text-sm text-text-secondary'
}
}"
@click="handleViewMoreDetails"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
const handleViewMoreDetails = () => {
window.open('https://www.comfy.org/cloud/pricing', '_blank')
}
</script>
<script setup lang="ts"></script>

View File

@@ -19,9 +19,12 @@
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between">
<div>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ tierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{
$t('subscription.perMonth')
}}</span>
@@ -59,7 +62,7 @@
class: 'text-text-primary'
}
}"
@click="manageSubscription"
@click="showSubscriptionDialog"
/>
<SubscribeButton
v-else
@@ -75,17 +78,6 @@
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
<div class="flex flex-col flex-1">
<div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="text-sm">
{{ $t('subscription.partnerNodesBalance') }}
</div>
<div class="flex items-center">
<div class="text-sm text-muted">
{{ $t('subscription.partnerNodesDescription') }}
</div>
</div>
</div>
<div
:class="
cn(
@@ -112,7 +104,7 @@
/>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-secondary">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
@@ -121,7 +113,7 @@
height="2rem"
/>
<div v-else class="text-2xl font-bold">
${{ totalCredits }}
{{ totalCredits }}
</div>
</div>
@@ -133,12 +125,18 @@
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ monthlyBonusCredits }}
<div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
{{ monthlyBonusCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.monthlyBonusDescription') }}
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="$t('subscription.creditsRemainingThisMonth')"
>
{{ $t('subscription.creditsRemainingThisMonth') }}
</div>
<Button
v-tooltip="refreshTooltip"
@@ -146,7 +144,7 @@
text
rounded
size="small"
class="h-4 w-4"
class="h-4 w-4 shrink-0"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
@@ -161,12 +159,18 @@
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ prepaidCredits }}
<div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
{{ prepaidCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.prepaidDescription') }}
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</div>
<Button
v-tooltip="$t('subscription.prepaidCreditsInfo')"
@@ -174,7 +178,7 @@
text
rounded
size="small"
class="h-4 w-4"
class="h-4 w-4 shrink-0"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
@@ -190,8 +194,7 @@
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-text-secondary underline hover:text-text-secondary"
style="text-decoration: underline"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
@@ -216,14 +219,47 @@
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="text-sm">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<SubscriptionBenefits />
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
<div
@@ -307,28 +343,79 @@
import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { cn } from '@/utils/tailwindUtil'
const { buildDocsUrl } = useExternalLink()
const { t } = useI18n()
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
formattedMonthlyPrice,
manageSubscription,
handleInvoiceHistory
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
// Tier data - hardcoded for Creator tier as requested
const tierName = computed(() => t('subscription.tiers.creator.name'))
const tierPrice = computed(() => t('subscription.tiers.creator.price'))
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed(() => {
const baseBenefits: Benefit[] = [
{
key: 'monthlyCredits',
type: 'metric',
value: t('subscription.tiers.creator.benefits.monthlyCredits'),
label: t('subscription.tiers.creator.benefits.monthlyCreditsLabel')
},
{
key: 'maxDuration',
type: 'metric',
value: t('subscription.tiers.creator.benefits.maxDuration'),
label: t('subscription.tiers.creator.benefits.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.tiers.creator.benefits.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.tiers.creator.benefits.addCreditsLabel')
},
{
key: 'customLoRAs',
type: 'feature',
label: t('subscription.tiers.creator.benefits.customLoRAsLabel')
}
]
return baseBenefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()

View File

@@ -1,5 +1,68 @@
<template>
<div class="relative grid h-full grid-cols-5">
<div
v-if="showStripePricingTable"
class="flex flex-col gap-6 rounded-[24px] border border-interface-stroke bg-[var(--p-dialog-background)] p-4 shadow-[0_25px_80px_rgba(5,6,12,0.45)] md:p-6"
>
<div
class="flex flex-col gap-6 md:flex-row md:items-start md:justify-between"
>
<div class="flex flex-col gap-2 text-left md:max-w-2xl">
<div
class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.3em] text-text-secondary"
>
{{ $t('subscription.required.title') }}
<CloudBadge
reverse-order
no-padding
background-color="var(--p-dialog-background)"
use-subscription
/>
</div>
<div class="text-3xl font-semibold leading-tight md:text-4xl">
{{ $t('subscription.description') }}
</div>
</div>
<Button
icon="pi pi-times"
text
rounded
class="h-10 w-10 shrink-0 text-text-secondary hover:bg-white/10"
:aria-label="$t('g.close')"
@click="handleClose"
/>
</div>
<StripePricingTable class="flex-1" />
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center">
<p class="text-sm text-text-secondary">
{{ $t('subscription.haveQuestions') }}
</p>
<div class="flex items-center gap-2">
<Button
:label="$t('subscription.contactUs')"
text
severity="secondary"
icon="pi pi-comments"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
@click="handleContactUs"
/>
<span class="text-sm text-text-secondary">{{ $t('g.or') }}</span>
<Button
:label="$t('subscription.viewEnterprise')"
text
severity="secondary"
icon="pi pi-external-link"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
@click="handleViewEnterprise"
/>
</div>
</div>
</div>
<div v-else class="legacy-dialog relative grid h-full grid-cols-5">
<!-- Custom close button -->
<Button
icon="pi pi-times"
@@ -7,7 +70,7 @@
rounded
class="absolute top-2.5 right-2.5 z-10 h-8 w-8 p-0 text-white hover:bg-white/20"
:aria-label="$t('g.close')"
@click="onClose"
@click="handleClose"
/>
<div
@@ -72,13 +135,19 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onBeforeUnmount, watch } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
defineProps<{
const props = defineProps<{
onClose: () => void
}>()
@@ -86,19 +155,119 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { formattedMonthlyPrice } = useSubscription()
const { formattedMonthlyPrice, fetchStatus, isActiveSubscription } =
useSubscription()
const { featureFlag } = useFeatureFlags()
const subscriptionTiersEnabled = featureFlag(
'subscription_tiers_enabled',
false
)
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const showStripePricingTable = computed(
() =>
subscriptionTiersEnabled.value &&
isCloud &&
window.__CONFIG__?.subscription_required
)
const POLL_INTERVAL_MS = 3000
const MAX_POLL_DURATION_MS = 5 * 60 * 1000
let pollInterval: number | null = null
let pollStartTime = 0
const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
const startPolling = () => {
stopPolling()
pollStartTime = Date.now()
const poll = async () => {
try {
await fetchStatus()
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
}
}
void poll()
pollInterval = window.setInterval(() => {
if (Date.now() - pollStartTime > MAX_POLL_DURATION_MS) {
stopPolling()
return
}
void poll()
}, POLL_INTERVAL_MS)
}
watch(
showStripePricingTable,
(enabled) => {
if (enabled) {
startPolling()
} else {
stopPolling()
}
},
{ immediate: true }
)
watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showStripePricingTable.value) {
emit('close', true)
}
}
)
const handleSubscribed = () => {
emit('close', true)
}
const handleClose = () => {
stopPolling()
props.onClose()
}
const handleContactUs = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
await commandStore.execute('Comfy.ContactSupport')
}
const handleViewEnterprise = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'docs',
is_external: true,
source: 'subscription'
})
window.open('https://www.comfy.org/cloud/enterprise', '_blank')
}
onBeforeUnmount(() => {
stopPolling()
})
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
.legacy-dialog :deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
:deep(.p-button) {
.legacy-dialog :deep(.p-button) {
color: white;
}
</style>

View File

@@ -0,0 +1,118 @@
import { createSharedComposable } from '@vueuse/core'
import { ref } from 'vue'
import { STRIPE_PRICING_TABLE_SCRIPT_SRC } from '@/config/stripePricingTableConfig'
function useStripePricingTableLoaderInternal() {
const isLoaded = ref(false)
const isLoading = ref(false)
const error = ref<Error | null>(null)
let pendingPromise: Promise<void> | null = null
const resolveLoaded = () => {
isLoaded.value = true
isLoading.value = false
pendingPromise = null
}
const resolveError = (err: Error) => {
error.value = err
isLoading.value = false
pendingPromise = null
}
const loadScript = (): Promise<void> => {
if (isLoaded.value) {
return Promise.resolve()
}
if (pendingPromise) {
return pendingPromise
}
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${STRIPE_PRICING_TABLE_SCRIPT_SRC}"]`
)
if (existingScript) {
isLoading.value = true
pendingPromise = new Promise<void>((resolve, reject) => {
existingScript.addEventListener(
'load',
() => {
existingScript.dataset.loaded = 'true'
resolveLoaded()
resolve()
},
{ once: true }
)
existingScript.addEventListener(
'error',
() => {
const err = new Error('Stripe pricing table script failed to load')
resolveError(err)
reject(err)
},
{ once: true }
)
// Check if script already loaded after attaching listeners
if (
existingScript.dataset.loaded === 'true' ||
(existingScript as any).readyState === 'complete' ||
(existingScript as any).complete
) {
existingScript.dataset.loaded = 'true'
resolveLoaded()
resolve()
}
})
return pendingPromise
}
isLoading.value = true
pendingPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = STRIPE_PRICING_TABLE_SCRIPT_SRC
script.async = true
script.dataset.loaded = 'false'
script.addEventListener(
'load',
() => {
script.dataset.loaded = 'true'
resolveLoaded()
resolve()
},
{ once: true }
)
script.addEventListener(
'error',
() => {
const err = new Error('Stripe pricing table script failed to load')
resolveError(err)
reject(err)
},
{ once: true }
)
document.head.appendChild(script)
})
return pendingPromise
}
return {
loadScript,
isLoaded,
isLoading,
error
}
}
export const useStripePricingTableLoader = createSharedComposable(
useStripePricingTableLoaderInternal
)

View File

@@ -9,11 +9,11 @@ import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { useDialogService } from '@/services/dialogService'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
type CloudSubscriptionCheckoutResponse = {
@@ -37,7 +37,7 @@ function useSubscriptionInternal() {
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const dialogService = useDialogService()
const { showSubscriptionRequiredDialog } = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
@@ -102,7 +102,7 @@ function useSubscriptionInternal() {
useTelemetry()?.trackSubscription('modal_opened')
}
void dialogService.showSubscriptionRequiredDialog()
void showSubscriptionRequiredDialog()
}
const shouldWatchCancellation = (): boolean =>

View File

@@ -1,58 +1,41 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
/**
* Composable for handling subscription credit calculations and formatting
*/
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
const { locale } = useI18n()
const totalCredits = computed(() => {
if (!authStore.balance?.amount_micros) return '0.00'
try {
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting total credits:',
error
)
return '0.00'
}
})
const formatBalance = (maybeCents?: number) => {
// Backend returns cents despite the *_micros naming convention.
const cents = maybeCents ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
})
return amount
}
const monthlyBonusCredits = computed(() => {
if (!authStore.balance?.cloud_credit_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(
authStore.balance.cloud_credit_balance_micros,
'usd'
)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting monthly bonus credits:',
error
)
return '0.00'
}
})
const totalCredits = computed(() =>
formatBalance(authStore.balance?.amount_micros)
)
const prepaidCredits = computed(() => {
if (!authStore.balance?.prepaid_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(
authStore.balance.prepaid_balance_micros,
'usd'
)
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting prepaid credits:',
error
)
return '0.00'
}
})
const monthlyBonusCredits = computed(() =>
formatBalance(authStore.balance?.cloud_credit_balance_micros)
)
const prepaidCredits = computed(() =>
formatBalance(authStore.balance?.prepaid_balance_micros)
)
const isLoadingBalance = computed(() => authStore.isFetchingBalance)

View File

@@ -1,4 +1,7 @@
import { defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -7,6 +10,14 @@ const DIALOG_KEY = 'subscription-required'
export const useSubscriptionDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
const showStripeDialog = computed(
() =>
flags.subscriptionTiersEnabled &&
isCloud &&
window.__CONFIG__?.subscription_required
)
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -25,7 +36,19 @@ export const useSubscriptionDialog = () => {
onClose: hide
},
dialogComponentProps: {
style: 'width: 700px;'
style: showStripeDialog.value
? 'width: min(1100px, 90vw); max-height: 90vh;'
: 'width: 700px;',
pt: showStripeDialog.value
? {
root: {
class: '!rounded-[32px] overflow-visible'
},
content: {
class: '!p-0 bg-transparent'
}
}
: undefined
}
})
}

View File

@@ -37,4 +37,7 @@ export type RemoteConfig = {
model_upload_button_enabled?: boolean
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
subscription_tiers_enabled?: boolean
stripe_publishable_key?: string
stripe_pricing_table_id?: string
}

View File

@@ -3,6 +3,7 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -11,6 +12,7 @@ import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
interface SettingPanelItem {
node: SettingTreeNode
@@ -33,6 +35,8 @@ export function useSettingUI(
const activeCategory = ref<SettingTreeNode | null>(null)
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useSubscription()
const { flags } = useFeatureFlags()
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
@@ -81,7 +85,7 @@ export function useSettingUI(
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
)
}
@@ -102,6 +106,12 @@ export function useSettingUI(
)
}
const shouldShowPlanCreditsPanel = computed(() => {
if (!subscriptionPanel) return false
if (!flags.subscriptionTiersEnabled) return true
return isActiveSubscription.value
})
const userPanel: SettingPanelItem = {
node: {
key: 'user',
@@ -154,9 +164,7 @@ export function useSettingUI(
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : []),
...(isCloud &&
window.__CONFIG__?.subscription_required &&
subscriptionPanel
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel]
: [])
].filter((panel) => panel.component)
@@ -191,8 +199,7 @@ export function useSettingUI(
children: [
userPanel.node,
...(isLoggedIn.value &&
isCloud &&
window.__CONFIG__?.subscription_required &&
shouldShowPlanCreditsPanel.value &&
subscriptionPanel
? [subscriptionPanel.node]
: []),

View File

@@ -1,6 +1,6 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
import { compare } from 'semver'
import { compare, valid } from 'semver'
import { computed, ref } from 'vue'
import { isCloud } from '@/platform/distribution/types'
@@ -24,10 +24,12 @@ export const useReleaseStore = defineStore('release', () => {
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
// Current ComfyUI version
const currentComfyUIVersion = computed(
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
)
const currentVersion = computed(() => {
if (isCloud) {
return systemStatsStore?.systemStats?.system?.cloud_version ?? ''
}
return systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
})
// Release data from settings
const locale = computed(() => settingStore.get('Comfy.Locale'))
@@ -55,22 +57,33 @@ export const useReleaseStore = defineStore('release', () => {
// Helper constants
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
const compareVersions = (
releaseVersion: string,
currentVer: string
): number => {
if (valid(releaseVersion) && valid(currentVer)) {
return compare(releaseVersion, currentVer)
}
// Non-semver (e.g. git hash): assume different = newer
return releaseVersion === currentVer ? 0 : 1
}
// New version available?
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
compare(
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
currentVersion.value || '0.0.0'
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
compare(
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
currentVersion.value || '0.0.0'
) === 0
)
@@ -158,23 +171,25 @@ export const useReleaseStore = defineStore('release', () => {
return true
})
// Show "What's New" popup
const shouldShowPopup = computed(() => {
// Only show on desktop version
if (!isElectron() || isCloud) {
if (!isElectron() && !isCloud) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isLatestVersion.value) {
if (!recentRelease.value) {
return false
}
// Skip version check if current version isn't semver (e.g. git hash)
const skipVersionCheck = !valid(currentVersion.value)
if (!skipVersionCheck && !isLatestVersion.value) {
return false
}
// Hide if already seen
if (
releaseVersion.value === recentRelease.value.version &&
releaseStatus.value === "what's new seen"
@@ -225,8 +240,7 @@ export const useReleaseStore = defineStore('release', () => {
return
}
// Skip fetching if notifications are disabled
if (!showVersionUpdates.value) {
if (!isCloud && !showVersionUpdates.value) {
return
}
@@ -248,8 +262,8 @@ export const useReleaseStore = defineStore('release', () => {
}
const fetchedReleases = await releaseService.getReleases({
project: 'comfyui',
current_version: currentComfyUIVersion.value,
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})

View File

@@ -0,0 +1,177 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
import ReleaseNotificationToast from './ReleaseNotificationToast.vue'
// Mock release data with realistic CMS content
const mockReleases: ReleaseNote[] = [
{
id: 1,
project: 'comfyui',
version: '1.2.3',
attention: 'medium',
published_at: '2024-01-15T10:00:00Z',
content: `# ComfyUI 1.2.3 Release
**What's new**
New features and improvements for better workflow management.
- **Enhanced Node Editor**: Improved performance for large workflows with 100+ nodes
- **Auto-save Feature**: Your work is now automatically saved every 30 seconds
- **New Model Support**: Added support for FLUX.1-dev and FLUX.1-schnell models
- **Bug Fixes**: Resolved memory leak issues in the backend processing`
},
{
id: 2,
project: 'comfyui',
version: '1.2.4',
attention: 'high',
published_at: '2024-02-01T14:30:00Z',
content: `# ComfyUI 1.2.4 Major Release
**What's new**
Revolutionary updates that change how you create with ComfyUI.
- **Real-time Collaboration**: Share and edit workflows with your team in real-time
- **Advanced Upscaling**: New ESRGAN and Real-ESRGAN models built-in
- **Custom Node Store**: Browse and install community nodes directly from the interface
- **Performance Boost**: 40% faster generation times for SDXL models
- **Dark Mode**: Beautiful new dark interface theme`
},
{
id: 3,
project: 'comfyui',
version: '1.3.0',
attention: 'high',
published_at: '2024-03-10T09:15:00Z',
content: `# ComfyUI 1.3.0 - The Biggest Update Yet
**What's new**
Introducing powerful new features that unlock creative possibilities.
- **AI-Powered Node Suggestions**: Get intelligent recommendations while building workflows
- **Workflow Templates**: Start from professionally designed templates
- **Advanced Queuing**: Batch process multiple generations with queue management
- **Mobile Preview**: Preview your workflows on mobile devices
- **API Improvements**: Enhanced REST API with better documentation
- **Community Hub**: Share workflows and discover creations from other users`
}
]
interface StoryArgs {
releaseData: ReleaseNote
}
const meta: Meta<StoryArgs> = {
title: 'Platform/Updates/ReleaseNotificationToast',
component: ReleaseNotificationToast,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
argTypes: {
releaseData: {
control: 'object',
description: 'Release data with version and markdown content'
}
},
decorators: [
(_, context) => {
// Set up the store with mock data for this story
const releaseStore = useReleaseStore()
// Patch store state directly for Storybook
releaseStore.$patch({
releases: [context.args.releaseData]
})
// Override shouldShowToast getter for Storybook
Object.defineProperty(releaseStore, 'shouldShowToast', {
get: () => true,
configurable: true
})
// Override recentRelease getter for Storybook
Object.defineProperty(releaseStore, 'recentRelease', {
get: () => context.args.releaseData,
configurable: true
})
// Mock the store methods to prevent errors
releaseStore.handleSkipRelease = async () => {
// Mock implementation for Storybook
}
releaseStore.handleShowChangelog = async () => {
// Mock implementation for Storybook
}
return {
template: `
<div class="min-h-screen flex items-center justify-center bg-base-background p-8">
<story />
</div>
`
}
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
releaseData: mockReleases[0]
}
}
export const MajorRelease: Story = {
args: {
releaseData: mockReleases[1]
}
}
export const ExtensiveFeatures: Story = {
args: {
releaseData: mockReleases[2]
}
}
export const LongContent: Story = {
args: {
releaseData: {
id: 4,
project: 'comfyui',
version: '1.4.0',
attention: 'high',
published_at: '2024-04-05T11:00:00Z',
content: `# ComfyUI 1.4.0 - Comprehensive Update
**What's new**
This is a comprehensive update with many new features and improvements. This release includes extensive changes across the entire platform.
- **Revolutionary Workflow Engine**: Complete rewrite of the workflow processing engine with 300% performance improvements
- **Advanced Model Management**: Sophisticated model organization with tagging, favorites, and automatic duplicate detection
- **Real-time Collaboration Suite**: Complete collaboration platform with user management, permissions, and shared workspaces
- **Professional Animation Tools**: Timeline-based animation system with keyframes and interpolation
- **Cloud Integration**: Seamless cloud storage integration with automatic backup and sync
- **Advanced Debugging Tools**: Comprehensive debugging suite with step-through execution and variable inspection`
}
}
}
export const EmptyContent: Story = {
args: {
releaseData: {
id: 5,
project: 'comfyui',
version: '1.0.0',
attention: 'low',
published_at: '2024-01-01T00:00:00Z',
content: ''
}
}
}

View File

@@ -0,0 +1,283 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReleaseNote } from '../common/releaseService'
import ReleaseNotificationToast from './ReleaseNotificationToast.vue'
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'releaseToast.newVersionAvailable': 'New update is out!',
'releaseToast.whatsNew': "See what's new",
'releaseToast.skip': 'Skip',
'releaseToast.update': 'Update',
'releaseToast.description':
'Check out the latest improvements and features in this update.'
}
return translations[key] || key
})
})),
createI18n: vi.fn(() => ({
global: {
locale: { value: 'en' }
}
}))
}))
vi.mock('@/utils/formatUtil', () => ({
formatVersionAnchor: vi.fn((version: string) => version.replace(/\./g, ''))
}))
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((content: string) => `<div>${content}</div>`)
}))
// Mock release store
const mockReleaseStore = {
recentRelease: null as ReleaseNote | null,
shouldShowToast: false,
handleSkipRelease: vi.fn(),
handleShowChangelog: vi.fn(),
releases: [],
fetchReleases: vi.fn()
}
vi.mock('../common/releaseStore', () => ({
useReleaseStore: vi.fn(() => mockReleaseStore)
}))
describe('ReleaseNotificationToast', () => {
let wrapper: VueWrapper<InstanceType<typeof ReleaseNotificationToast>>
const mountComponent = (props = {}) => {
return mount(ReleaseNotificationToast, {
global: {
mocks: {
$t: (key: string) => {
const translations: Record<string, string> = {
'releaseToast.newVersionAvailable': 'New update is out!',
'releaseToast.whatsNew': "See what's new",
'releaseToast.skip': 'Skip',
'releaseToast.update': 'Update',
'releaseToast.description':
'Check out the latest improvements and features in this update.'
}
return translations[key] || key
}
},
stubs: {
// Stub Lucide icons
'i-lucide-rocket': true,
'i-lucide-external-link': true
}
},
props
})
}
beforeEach(() => {
vi.clearAllMocks()
// Reset store state
mockReleaseStore.recentRelease = null
mockReleaseStore.shouldShowToast = true // Force show for testing
})
it('renders correctly when shouldShow is true', () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release\n\nSome content'
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
})
it('displays rocket icon', () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.find('.icon-\\[lucide--rocket\\]').exists()).toBe(true)
})
it('displays release version', () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.text()).toContain('1.2.3')
})
it('calls handleSkipRelease when skip button is clicked', async () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
const buttons = wrapper.findAll('button')
const skipButton = buttons.find(
(btn) =>
btn.text().includes('Skip') || btn.element.innerHTML.includes('skip')
)
expect(skipButton).toBeDefined()
await skipButton!.trigger('click')
expect(mockReleaseStore.handleSkipRelease).toHaveBeenCalledWith('1.2.3')
})
it('opens update URL when update button is clicked', async () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
// Mock window.open
const mockWindowOpen = vi.fn()
Object.defineProperty(window, 'open', {
value: mockWindowOpen,
writable: true
})
wrapper = mountComponent()
// Call the handler directly instead of triggering DOM event
await wrapper.vm.handleUpdate()
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://docs.comfy.org/installation/update_comfyui',
'_blank'
)
})
it('calls handleShowChangelog when learn more link is clicked', async () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
// Call the handler directly instead of triggering DOM event
await wrapper.vm.handleLearnMore()
expect(mockReleaseStore.handleShowChangelog).toHaveBeenCalledWith('1.2.3')
})
it('generates correct changelog URL', () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
const learnMoreLink = wrapper.find('a[target="_blank"]')
expect(learnMoreLink.exists()).toBe(true)
expect(learnMoreLink.attributes('href')).toContain(
'docs.comfy.org/changelog'
)
})
it('removes title from markdown content for toast display', async () => {
const mockMarkdownRendererModule = (await vi.importMock(
'@/utils/markdownRendererUtil'
)) as { renderMarkdownToHtml: ReturnType<typeof vi.fn> }
const mockMarkdownRenderer = vi.mocked(
mockMarkdownRendererModule.renderMarkdownToHtml
)
mockMarkdownRenderer.mockReturnValue('<div>Content without title</div>')
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release Title\n\nSome content'
} as ReleaseNote
wrapper = mountComponent()
// Should call markdown renderer with title removed
expect(mockMarkdownRenderer).toHaveBeenCalledWith('\n\nSome content')
})
it('fetches releases on mount when not already loaded', async () => {
mockReleaseStore.releases = [] // Empty releases array
wrapper = mountComponent()
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
})
it('handles missing release content gracefully', () => {
mockReleaseStore.shouldShowToast = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: ''
} as ReleaseNote
wrapper = mountComponent()
// Should render fallback content
const descriptionElement = wrapper.find('.pl-14')
expect(descriptionElement.exists()).toBe(true)
expect(descriptionElement.text()).toContain('Check out the latest')
})
it('auto-hides after timeout', async () => {
vi.useFakeTimers()
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
// Initially visible
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
// Fast-forward time to trigger auto-hide
vi.advanceTimersByTime(8000)
await wrapper.vm.$nextTick()
// Component should call dismissToast internally which hides it
// We can't test DOM visibility change because the component uses local state
// But we can verify the timer was set and would have triggered
expect(vi.getTimerCount()).toBe(0) // Timer should be cleared after auto-hide
vi.useRealTimers()
})
it('clears auto-hide timer when manually dismissed', async () => {
vi.useFakeTimers()
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
// Start the timer
vi.advanceTimersByTime(1000)
// Manually dismiss by calling handler directly
await wrapper.vm.handleSkip()
// Timer should be cleared
expect(vi.getTimerCount()).toBe(0)
// Verify the store method was called (manual dismissal)
expect(mockReleaseStore.handleSkipRelease).toHaveBeenCalled()
vi.useRealTimers()
})
})

View File

@@ -1,43 +1,63 @@
<template>
<div v-if="shouldShow" class="release-toast-popup">
<div class="release-notification-toast">
<!-- Header section with icon and text -->
<div class="toast-header">
<div class="toast-icon">
<i class="pi pi-download" />
</div>
<div class="toast-text">
<div class="toast-title">
{{ $t('releaseToast.newVersionAvailable') }}
<div
class="w-96 max-h-96 bg-base-background border border-border-default rounded-lg shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)] flex flex-col"
>
<!-- Main content -->
<div class="p-4 flex flex-col gap-4 flex-1 min-h-0">
<!-- Header section with icon and text -->
<div class="flex items-center gap-4">
<div
class="p-3 bg-primary-background-hover rounded-lg flex items-center justify-center shrink-0"
>
<i class="icon-[lucide--rocket] w-4 h-4 text-white" />
</div>
<div class="toast-version-badge">
{{ latestRelease?.version }}
<div class="flex flex-col gap-1">
<div
class="text-sm font-normal text-base-foreground leading-[1.429]"
>
{{ $t('releaseToast.newVersionAvailable') }}
</div>
<div
class="text-sm font-normal text-muted-foreground leading-[1.21]"
>
{{ latestRelease?.version }}
</div>
</div>
</div>
<!-- Description section -->
<div
class="pl-14 text-sm font-normal text-muted-foreground leading-[1.21] overflow-y-auto flex-1 min-h-0"
v-html="formattedContent"
></div>
</div>
<!-- Actions section -->
<div class="toast-actions-section">
<div class="actions-row">
<div class="left-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
@click="handleLearnMore"
>
{{ $t('releaseToast.whatsNew') }}
</a>
</div>
<div class="right-actions">
<button class="skip-button" @click="handleSkip">
{{ $t('releaseToast.skip') }}
</button>
<button class="cta-button" @click="handleUpdate">
{{ $t('releaseToast.update') }}
</button>
</div>
<!-- Footer section -->
<div class="flex justify-between items-center px-4 pb-4">
<a
class="flex items-center gap-2 text-sm font-normal py-1 text-muted-foreground hover:text-base-foreground"
:href="changelogUrl"
target="_blank"
rel="noopener noreferrer"
@click="handleLearnMore"
>
<i class="icon-[lucide--external-link] w-4 h-4"></i>
{{ $t('releaseToast.whatsNew') }}
</a>
<div class="flex items-center gap-4">
<button
class="h-6 px-0 bg-transparent border-none text-sm font-normal text-muted-foreground hover:text-base-foreground cursor-pointer"
@click="handleSkip"
>
{{ $t('releaseToast.skip') }}
</button>
<button
class="h-10 px-4 bg-secondary-background hover:bg-secondary-background-hover rounded-lg border-none text-sm font-normal text-base-foreground cursor-pointer"
@click="handleUpdate"
>
{{ $t('releaseToast.update') }}
</button>
</div>
</div>
</div>
@@ -45,24 +65,28 @@
</template>
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useExternalLink } from '@/composables/useExternalLink'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
const { buildDocsUrl } = useExternalLink()
const releaseStore = useReleaseStore()
const { t } = useI18n()
// Local state for dismissed status
const isDismissed = ref(false)
// Get latest release from store
const latestRelease = computed<ReleaseNote | null>(
() => releaseStore.recentRelease
)
const latestRelease = computed<ReleaseNote | null>(() => {
return releaseStore.recentRelease
})
// Show toast when new version available and not dismissed
const shouldShow = computed(
@@ -79,6 +103,38 @@ const changelogUrl = computed(() => {
return changelogBaseUrl
})
const formattedContent = computed(() => {
if (!latestRelease.value?.content) {
return DOMPurify.sanitize(`<p>${t('releaseToast.description')}</p>`)
}
try {
const markdown = latestRelease.value.content
// Remove the h1 title line and images for toast mode
const contentWithoutTitle = markdown.replace(/^# .+$/m, '')
const contentWithoutImages = contentWithoutTitle.replace(
/!\[.*?\]\(.*?\)/g,
''
)
// Check if there's meaningful content left after cleanup
const trimmedContent = contentWithoutImages.trim()
if (!trimmedContent || trimmedContent.replace(/\s+/g, '') === '') {
return DOMPurify.sanitize(`<p>${t('releaseToast.description')}</p>`)
}
// renderMarkdownToHtml already sanitizes with DOMPurify, so this is safe
return renderMarkdownToHtml(contentWithoutImages)
} catch (error) {
console.error('Error parsing markdown:', error)
// Fallback to plain text with line breaks - sanitize the HTML we create
const fallbackContent = latestRelease.value.content.replace(/\n/g, '<br>')
return fallbackContent.trim()
? DOMPurify.sanitize(fallbackContent)
: DOMPurify.sanitize(`<p>${t('releaseToast.description')}</p>`)
}
})
// Auto-hide timer
let hideTimer: ReturnType<typeof setTimeout> | null = null
@@ -124,8 +180,6 @@ const handleUpdate = () => {
dismissToast()
}
// Learn more handled by anchor href
// Start auto-hide when toast becomes visible
watch(shouldShow, (isVisible) => {
if (isVisible) {
@@ -142,6 +196,13 @@ onMounted(async () => {
await releaseStore.fetchReleases()
}
})
// Expose methods for testing
defineExpose({
handleSkip,
handleLearnMore,
handleUpdate
})
</script>
<style scoped>
@@ -154,10 +215,7 @@ onMounted(async () => {
}
/* Sidebar positioning classes applied by parent - matching help center */
.release-toast-popup.sidebar-left {
left: 1rem;
}
.release-toast-popup.sidebar-left,
.release-toast-popup.sidebar-left.small-sidebar {
left: 1rem;
}
@@ -165,139 +223,4 @@ onMounted(async () => {
.release-toast-popup.sidebar-right {
right: 1rem;
}
/* Main toast container */
.release-notification-toast {
width: 448px;
padding: 16px 16px 8px;
background: #353535;
box-shadow: 0 4px 4px rgb(0 0 0 / 0.25);
border-radius: 12px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Header section */
.toast-header {
display: flex;
gap: 16px;
align-items: flex-start;
}
/* Icon container */
.toast-icon {
width: 42px;
height: 42px;
padding: 10px;
background: rgb(0 122 255 / 0.2);
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.toast-icon i {
color: #007aff;
font-size: 16px;
}
/* Text content */
.toast-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.toast-title {
color: white;
font-size: 14px;
font-weight: 500;
line-height: 18.2px;
}
.toast-version-badge {
color: #a0a1a2;
font-size: 12px;
font-weight: 500;
line-height: 15.6px;
}
/* Actions section */
.toast-actions-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions-row {
padding-left: 58px; /* Align with text content */
padding-right: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.left-actions {
display: flex;
align-items: center;
}
/* Learn more link - simple text link */
.learn-more-link {
color: #60a5fa;
font-size: 12px;
font-weight: 500;
line-height: 15.6px;
text-decoration: none;
}
.learn-more-link:hover {
text-decoration: underline;
}
.right-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Button styles */
.skip-button {
padding: 8px 16px;
background: #353535;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: #aeaeb2;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.skip-button:hover {
background: #404040;
}
.cta-button {
padding: 8px 16px;
background: white;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: black;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.cta-button:hover {
background: #f0f0f0;
}
</style>

View File

@@ -0,0 +1,211 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
import WhatsNewPopup from './WhatsNewPopup.vue'
// Mock release data with realistic CMS content
const mockReleases: ReleaseNote[] = [
{
id: 1,
project: 'comfyui',
version: '1.2.3',
attention: 'medium',
published_at: '2024-01-15T10:00:00Z',
content: `# ComfyUI 1.2.3 Release
**What's new**
New features and improvements for better workflow management.
- **Enhanced Node Editor**: Improved performance for large workflows with 100+ nodes
- **Auto-save Feature**: Your work is now automatically saved every 30 seconds
- **New Model Support**: Added support for FLUX.1-dev and FLUX.1-schnell models
- **Bug Fixes**: Resolved memory leak issues in the backend processing`
},
{
id: 2,
project: 'comfyui',
version: '1.2.4',
attention: 'high',
published_at: '2024-02-01T14:30:00Z',
content: `![Featured Image](https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=200&fit=crop&fm=jpg)
# ComfyUI 1.2.4 Major Release
**What's new**
Revolutionary updates that change how you create with ComfyUI.
- **Real-time Collaboration**: Share and edit workflows with your team in real-time
- **Advanced Upscaling**: New ESRGAN and Real-ESRGAN models built-in
- **Custom Node Store**: Browse and install community nodes directly from the interface
- **Performance Boost**: 40% faster generation times for SDXL models
- **Dark Mode**: Beautiful new dark interface theme`
},
{
id: 3,
project: 'comfyui',
version: '1.3.0',
attention: 'high',
published_at: '2024-03-10T09:15:00Z',
content: `![Release Image](https://images.unsplash.com/photo-1555041469-a586c61ea9bc?w=400&h=200&fit=crop&fm=jpg)
# ComfyUI 1.3.0 - The Biggest Update Yet
**What's new**
Introducing powerful new features that unlock creative possibilities.
- **AI-Powered Node Suggestions**: Get intelligent recommendations while building workflows
- **Workflow Templates**: Start from professionally designed templates
- **Advanced Queuing**: Batch process multiple generations with queue management
- **Mobile Preview**: Preview your workflows on mobile devices
- **API Improvements**: Enhanced REST API with better documentation
- **Community Hub**: Share workflows and discover creations from other users`
}
]
interface StoryArgs {
releaseData: ReleaseNote
}
const meta: Meta<StoryArgs> = {
title: 'Platform/Updates/WhatsNewPopup',
component: WhatsNewPopup,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
argTypes: {
releaseData: {
control: 'object',
description: 'Release data with version and markdown content'
}
},
decorators: [
(_story, context) => {
// Set up the store with mock data for this story
const releaseStore = useReleaseStore()
// Override store data with story args
releaseStore.releases = [context.args.releaseData]
// Force the computed properties to return the values we want
Object.defineProperty(releaseStore, 'recentRelease', {
value: context.args.releaseData,
writable: true
})
Object.defineProperty(releaseStore, 'shouldShowPopup', {
value: true,
writable: true
})
// Mock the store methods to prevent errors
releaseStore.handleWhatsNewSeen = async () => {
// Mock implementation for Storybook
}
return {
template: `
<div class="min-h-screen flex items-center justify-center bg-gray-900 p-8">
<story />
</div>
`
}
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
releaseData: mockReleases[0]
}
}
export const WithImage: Story = {
args: {
releaseData: mockReleases[1]
}
}
export const MajorRelease: Story = {
args: {
releaseData: mockReleases[2]
}
}
export const LongContent: Story = {
args: {
releaseData: {
id: 4,
project: 'comfyui',
version: '2.0.0',
attention: 'high',
published_at: '2024-04-20T16:00:00Z',
content: `![Major Update](https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=200&fit=crop)
# ComfyUI 2.0.0 - Complete Rewrite
**What's new**
The most significant update in ComfyUI history with complete platform rewrite.
## Core Engine Improvements
- **Next-Generation Workflow Engine**: Completely rewritten from the ground up with 500% performance improvements for complex workflows
- **Advanced Memory Management**: Intelligent memory allocation reducing VRAM usage by up to 60% while maintaining quality
- **Multi-Threading Support**: Full multi-core CPU utilization for preprocessing and post-processing tasks
- **GPU Optimization**: Advanced GPU scheduling with automatic optimization for different hardware configurations
## New User Interface
- **Modern Design Language**: Beautiful new interface with improved accessibility and mobile responsiveness
- **Customizable Workspace**: Fully customizable layout with dockable panels and saved workspace configurations
- **Advanced Node Browser**: Intelligent node search with AI-powered suggestions and visual node previews
- **Real-time Preview**: Live preview of changes as you build your workflow without needing to execute
## Professional Features
- **Version Control Integration**: Native Git integration for workflow version control and collaboration
- **Enterprise Security**: Advanced security features including end-to-end encryption and audit logging
- **Scalable Architecture**: Designed to handle enterprise-scale deployments with thousands of concurrent users
- **Plugin Ecosystem**: Robust plugin system with hot-loading and automatic dependency management`
}
}
}
export const MinimalContent: Story = {
args: {
releaseData: {
id: 5,
project: 'comfyui',
version: '1.0.1',
attention: 'low',
published_at: '2024-01-05T12:00:00Z',
content: `# ComfyUI 1.0.1
**What's new**
Quick patch release.
- **Bug Fix**: Fixed critical save issue`
}
}
}
export const EmptyContent: Story = {
args: {
releaseData: {
id: 6,
project: 'comfyui',
version: '1.0.0',
attention: 'low',
published_at: '2024-01-01T00:00:00Z',
content: ''
}
}
}

View File

@@ -0,0 +1,213 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReleaseNote } from '../common/releaseService'
import WhatsNewPopup from './WhatsNewPopup.vue'
// Mock dependencies
const mockTranslations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.later': 'Later',
'whatsNewPopup.learnMore': 'Learn More',
'whatsNewPopup.noReleaseNotes': 'No release notes available'
}
vi.mock('@/i18n', () => ({
i18n: {
global: {
locale: {
value: 'en'
}
}
},
t: (key: string, params?: Record<string, string>) => {
return params
? `${mockTranslations[key] || key}:${JSON.stringify(params)}`
: mockTranslations[key] || key
},
d: (date: Date) => date.toLocaleDateString()
}))
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: vi.fn((key: string) => {
return mockTranslations[key] || key
})
}))
}))
vi.mock('@/utils/formatUtil', () => ({
formatVersionAnchor: vi.fn((version: string) => version.replace(/\./g, ''))
}))
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((content: string) => `<div>${content}</div>`)
}))
// Mock release store
const mockReleaseStore = {
recentRelease: null as ReleaseNote | null,
shouldShowPopup: false,
handleWhatsNewSeen: vi.fn(),
releases: [] as ReleaseNote[],
fetchReleases: vi.fn()
}
vi.mock('../common/releaseStore', () => ({
useReleaseStore: vi.fn(() => mockReleaseStore)
}))
describe('WhatsNewPopup', () => {
let wrapper: VueWrapper
const mountComponent = (props = {}) => {
return mount(WhatsNewPopup, {
global: {
plugins: [PrimeVue],
components: { Button },
mocks: {
$t: (key: string) => {
return mockTranslations[key] || key
}
},
stubs: {
// Stub Lucide icons
'i-lucide-x': true,
'i-lucide-external-link': true
}
},
props
})
}
beforeEach(() => {
vi.clearAllMocks()
// Reset store state
mockReleaseStore.recentRelease = null
mockReleaseStore.shouldShowPopup = false
mockReleaseStore.releases = []
mockReleaseStore.handleWhatsNewSeen = vi.fn()
mockReleaseStore.fetchReleases = vi.fn()
})
it('renders correctly when shouldShow is true', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release\n\nSome content'
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.find('.whats-new-popup').exists()).toBe(true)
})
it('does not render when shouldShow is false', () => {
mockReleaseStore.shouldShowPopup = false
wrapper = mountComponent()
expect(wrapper.find('.whats-new-popup').exists()).toBe(false)
})
it('calls handleWhatsNewSeen when close button is clicked', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
const closeButton = wrapper.findComponent(Button)
await closeButton.trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.2.3')
})
it('generates correct changelog URL', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toContain(
'docs.comfy.org/changelog'
)
})
it('handles missing release content gracefully', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: ''
} as ReleaseNote
wrapper = mountComponent()
// Should render fallback content
const contentElement = wrapper.find('.content-text')
expect(contentElement.exists()).toBe(true)
})
it('emits whats-new-dismissed event when popup is closed', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
// Call the close method directly instead of triggering DOM event
await (wrapper.vm as any).closePopup()
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
it('fetches releases on mount when not already loaded', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.releases = [] // Empty releases array
wrapper = mountComponent()
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
})
it('does not fetch releases when already loaded', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.releases = [{ version: '1.0.0' } as ReleaseNote] // Non-empty releases array
wrapper = mountComponent()
expect(mockReleaseStore.fetchReleases).not.toHaveBeenCalled()
})
it('processes markdown content correctly', async () => {
const mockMarkdownRendererModule = (await vi.importMock(
'@/utils/markdownRendererUtil'
)) as { renderMarkdownToHtml: ReturnType<typeof vi.fn> }
const mockMarkdownRenderer = vi.mocked(
mockMarkdownRendererModule.renderMarkdownToHtml
)
mockMarkdownRenderer.mockReturnValue('<h1>Processed Content</h1>')
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Original Title\n\nContent'
} as ReleaseNote
wrapper = mountComponent()
// Should call markdown renderer with original content (no modification)
expect(mockMarkdownRenderer).toHaveBeenCalledWith(
'# Original Title\n\nContent'
)
})
})

View File

@@ -1,62 +1,50 @@
<template>
<div v-if="shouldShow" class="whats-new-popup-container">
<!-- Arrow pointing to help center -->
<div class="help-center-arrow">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="19"
viewBox="0 0 16 19"
fill="none"
>
<!-- Arrow fill -->
<path
d="M15.25 1.27246L15.25 17.7275L0.999023 9.5L15.25 1.27246Z"
fill="#353535"
/>
<!-- Top and bottom outlines only -->
<path
d="M15.25 1.27246L0.999023 9.5"
stroke="#4e4e4e"
stroke-width="1"
fill="none"
/>
<path
d="M0.999023 9.5L15.25 17.7275"
stroke="#4e4e4e"
stroke-width="1"
fill="none"
/>
</svg>
</div>
<div v-if="shouldShow" class="whats-new-popup-container left-4">
<div class="whats-new-popup" @click.stop>
<!-- Close Button -->
<button
class="close-button"
<Button
class="close-button absolute top-2 right-2 z-10 w-8 h-8 p-2 rounded-lg opacity-50"
:aria-label="$t('g.close')"
icon="icon-[lucide--x]"
size="small"
severity="secondary"
text
@click="closePopup"
/>
<!-- Modal Body -->
<div class="modal-body flex flex-col gap-4 px-0 pt-0 pb-2 flex-1">
<!-- Release Content -->
<div
class="content-text max-h-96 overflow-y-auto"
v-html="formattedContent"
></div>
</div>
<!-- Modal Footer -->
<div
class="modal-footer flex justify-between items-center gap-4 px-4 pb-4"
>
<div class="close-icon"></div>
</button>
<!-- Release Content -->
<div class="popup-content">
<div class="content-text" v-html="formattedContent"></div>
<!-- Actions Section -->
<div class="popup-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
<a
class="learn-more-link flex items-center gap-2 text-sm font-normal py-1"
:href="changelogUrl"
target="_blank"
rel="noopener noreferrer"
@click="closePopup"
>
<i class="icon-[lucide--external-link]"></i>
{{ $t('whatsNewPopup.learnMore') }}
</a>
<div class="footer-actions flex items-center gap-4">
<Button
class="h-8"
size="small"
severity="secondary"
text
@click="closePopup"
>
{{ $t('whatsNewPopup.learnMore') }}
</a>
<!-- TODO: CTA button -->
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
{{ $t('whatsNewPopup.later') }}
</Button>
</div>
</div>
</div>
@@ -64,6 +52,8 @@
</template>
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import Button from 'primevue/button'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -74,9 +64,9 @@ import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
const { t } = useI18n()
const { buildDocsUrl } = useExternalLink()
const releaseStore = useReleaseStore()
const { t } = useI18n()
// Define emits
const emit = defineEmits<{
@@ -87,9 +77,9 @@ const emit = defineEmits<{
const isDismissed = ref(false)
// Get latest release from store
const latestRelease = computed<ReleaseNote | null>(
() => releaseStore.recentRelease
)
const latestRelease = computed<ReleaseNote | null>(() => {
return releaseStore.recentRelease
})
// Show popup when on latest version and not dismissed
const shouldShow = computed(
@@ -108,15 +98,39 @@ const changelogUrl = computed(() => {
const formattedContent = computed(() => {
if (!latestRelease.value?.content) {
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
return DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
}
try {
return renderMarkdownToHtml(latestRelease.value.content)
const markdown = latestRelease.value.content
// Check if content is meaningful (not just whitespace)
const trimmedContent = markdown.trim()
if (!trimmedContent || trimmedContent.replace(/\s+/g, '') === '') {
return DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
}
// Extract image and remaining content separately
const imageMatch = markdown.match(/!\[.*?\]\(.*?\)/)
const image = imageMatch ? imageMatch[0] : ''
// Remove image from content but keep original title
const contentWithoutImage = markdown.replace(/!\[.*?\]\(.*?\)/, '').trim()
// Reorder: image first, then original content
const reorderedContent = [image, contentWithoutImage]
.filter(Boolean)
.join('\n\n')
// renderMarkdownToHtml already sanitizes with DOMPurify, so this is safe
return renderMarkdownToHtml(reorderedContent)
} catch (error) {
console.error('Error parsing markdown:', error)
// Fallback to plain text with line breaks
return latestRelease.value.content.replace(/\n/g, '<br>')
// Fallback to plain text with line breaks - sanitize the HTML we create
const fallbackContent = latestRelease.value.content.replace(/\n/g, '<br>')
return fallbackContent.trim()
? DOMPurify.sanitize(fallbackContent)
: DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
}
})
@@ -145,10 +159,11 @@ onMounted(async () => {
}
})
// Expose methods for parent component
// Expose methods for parent component and tests
defineExpose({
show,
hide
hide,
closePopup
})
</script>
@@ -163,165 +178,80 @@ defineExpose({
pointer-events: auto;
}
/* Arrow pointing to help center */
.help-center-arrow {
position: absolute;
bottom: calc(
var(--sidebar-width) * 2 + var(--sidebar-width) / 2
); /* Position to center of help center icon (2 icons below + half icon height for center) */
transform: none;
z-index: 999;
pointer-events: none;
}
/* Position arrow based on sidebar location */
.whats-new-popup-container.sidebar-left .help-center-arrow {
left: -14px; /* Overlap with popup outline */
}
.whats-new-popup-container.sidebar-left.small-sidebar .help-center-arrow {
left: -14px; /* Overlap with popup outline */
bottom: calc(
var(--sidebar-width) * 2 + var(--sidebar-icon-size) / 2 -
var(--whats-new-popup-bottom)
); /* Position to center of help center icon (2 icons below + half icon height for center - what's new popup bottom position ) */
}
/* Sidebar positioning classes applied by parent */
.whats-new-popup-container.sidebar-left {
left: 1rem;
}
.whats-new-popup-container.sidebar-left.small-sidebar {
left: 1rem;
}
.whats-new-popup-container.sidebar-right {
right: 1rem;
}
.whats-new-popup {
background: #353535;
border-radius: 12px;
background: var(--interface-menu-surface);
border-radius: 8px;
max-width: 400px;
width: 400px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
box-shadow: 0 8px 32px rgb(0 0 0 / 0.3);
border: 1px solid var(--interface-menu-stroke);
box-shadow: 1px 1px 8px 0 rgb(0 0 0 / 0.2);
position: relative;
}
/* Content Section */
.popup-content {
display: flex;
flex-direction: column;
gap: 24px;
max-height: 80vh;
overflow-y: auto;
padding: 32px 32px 24px;
border-radius: 12px;
}
/* Close button */
.close-button {
position: absolute;
top: 0;
right: 0;
width: 32px;
height: 32px;
padding: 6px;
background: #7c7c7c;
border-radius: 16px;
border: none;
cursor: pointer;
/* Modal Body */
.modal-body {
display: flex;
justify-content: center;
align-items: center;
transform: translate(30%, -30%);
transition:
background-color 0.2s ease,
transform 0.1s ease;
z-index: 1;
flex-direction: column;
gap: 1rem;
padding: 0;
flex: 1;
}
.close-button:hover {
background: #8e8e8e;
}
.close-button:active {
background: #6a6a6a;
transform: translate(30%, -30%) scale(0.95);
}
.close-icon {
width: 16px;
height: 16px;
position: relative;
opacity: 0.9;
transition: opacity 0.2s ease;
}
.close-button:hover .close-icon {
opacity: 1;
}
.close-icon::before,
.close-icon::after {
content: '';
position: absolute;
width: 12px;
height: 2px;
background: white;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg);
transition: background-color 0.2s ease;
}
.close-icon::after {
transform: translate(-50%, -50%) rotate(-45deg);
.modal-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.content-text {
color: white;
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
padding: 0 1rem;
}
/* Style the markdown content */
/* Title */
.content-text :deep(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.content-text :deep(h1) {
font-size: 16px;
font-weight: 700;
color: var(--text-secondary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 400;
margin-top: 1rem;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Version subtitle - targets the first p tag after h1 */
.content-text :deep(h1 + p) {
color: #c0c0c0;
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
opacity: 0.8;
/* What's new title - targets h2 or strong text after h1 */
.content-text :deep(h2),
.content-text :deep(h1 + p strong) {
color: var(--text-primary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.429;
}
/* Regular paragraphs - short description */
.content-text :deep(p) {
margin-bottom: 16px;
color: #e0e0e0;
color: var(--text-secondary);
font-family: Inter, sans-serif;
margin: 1rem 0;
}
/* List */
.content-text :deep(ul),
.content-text :deep(ol) {
margin-bottom: 16px;
margin-bottom: 0;
padding-left: 0;
list-style: none;
}
@@ -336,110 +266,168 @@ defineExpose({
margin-bottom: 0;
}
/* List items */
.content-text :deep(li) {
margin-bottom: 8px;
margin-bottom: 6px;
position: relative;
padding-left: 20px;
padding-left: 18px;
color: var(--text-secondary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.2102;
}
.content-text :deep(li:last-child) {
margin-bottom: 0;
}
/* Custom bullet points */
.content-text :deep(li::before) {
content: '';
position: absolute;
left: 0;
top: 10px;
display: flex;
width: 8px;
height: 8px;
justify-content: center;
align-items: center;
aspect-ratio: 1/1;
border-radius: 100px;
background: #60a5fa;
left: 4px;
top: 7px;
width: 6px;
height: 6px;
border: 2px solid var(--text-secondary);
border-radius: 50%;
background: transparent;
}
/* List item strong text */
.content-text :deep(li strong) {
color: #fff;
color: var(--text-secondary);
font-family: Inter, sans-serif;
font-size: 14px;
display: block;
margin-bottom: 4px;
font-weight: 400;
line-height: 1.2102;
margin-right: 4px;
}
.content-text :deep(li p) {
font-size: 12px;
margin-bottom: 0;
line-height: 2;
margin: 2px 0 0;
display: inline;
}
/* Code styling */
.content-text :deep(code) {
background-color: #2a2a2a;
border: 1px solid #4a4a4a;
background-color: var(--input-surface);
border: 1px solid var(--interface-menu-stroke);
border-radius: 4px;
padding: 2px 6px;
color: #f8f8f2;
color: var(--text-primary);
white-space: nowrap;
}
/* Remove top margin for first media element */
.content-text :deep(img:first-child),
.content-text :deep(video:first-child),
.content-text :deep(iframe:first-child) {
margin-top: -32px; /* Align with the top edge of the popup content */
margin-bottom: 24px;
}
/* Media elements */
.content-text :deep(img),
.content-text :deep(video),
.content-text :deep(iframe) {
width: calc(100% + 64px);
height: auto;
margin: 24px -32px;
.content-text :deep(img) {
width: 100%;
height: 200px;
margin: 0 0 16px;
object-fit: cover;
display: block;
border-radius: 8px;
}
/* Actions Section */
.popup-actions {
.content-text :deep(img:first-child) {
margin: -1rem -1rem 16px;
width: calc(100% + 2rem);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
/* Add border to content when image is present */
.content-text:has(img:first-child) {
border-left: 1px solid var(--interface-menu-stroke);
border-right: 1px solid var(--interface-menu-stroke);
border-top: 1px solid var(--interface-menu-stroke);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
margin: -1px;
margin-bottom: 0;
}
.content-text :deep(img + h1) {
margin-top: 0;
}
/* Secondary headings */
.content-text :deep(h3) {
color: var(--text-primary);
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 600;
margin: 16px 0 8px;
line-height: 1.4;
}
/* Modal Footer */
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
gap: 16px;
padding: 16px;
border-top: none;
}
.footer-actions {
display: flex;
align-items: center;
gap: 16px;
}
.learn-more-link {
color: #60a5fa;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
line-height: 18.2px;
font-weight: 400;
line-height: 1.2102;
text-decoration: none;
padding: 4px 0;
}
.learn-more-link:hover {
text-decoration: underline;
color: var(--text-primary);
}
.cta-button {
height: 40px;
padding: 0 20px;
background: white;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
.learn-more-link i {
width: 16px;
height: 16px;
}
.action-secondary {
height: 32px;
padding: 4px 0;
background: transparent;
border: none;
color: #121212;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
font-weight: 400;
line-height: 1.2102;
cursor: pointer;
border-radius: 4px;
}
.action-secondary:hover {
color: var(--text-primary);
}
.action-primary {
height: 40px;
padding: 8px 16px;
background: var(--interface-menu-component-surface-hovered);
border-radius: 8px;
border: none;
color: var(--text-primary);
font-size: 14px;
font-weight: 400;
line-height: 1.2102;
cursor: pointer;
}
.cta-button:hover {
background: #f0f0f0;
.action-primary:hover {
background: var(--button-hover-surface);
}
</style>

View File

@@ -29,24 +29,16 @@
</p>
</div>
<!-- Loading State -->
<Skeleton
v-if="isLoading && !imageError"
border-radius="5px"
width="100%"
height="100%"
/>
<div v-if="showLoader && !imageError" class="size-full">
<Skeleton border-radius="5px" width="100%" height="100%" />
</div>
<!-- Main Image -->
<img
v-if="!imageError"
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
:class="
cn(
'block size-full object-contain pointer-events-none',
isLoading && 'invisible'
)
"
class="block size-full object-contain pointer-events-none"
@load="handleImageLoad"
@error="handleImageError"
/>
@@ -91,7 +83,7 @@
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-base-foreground">
<span v-else-if="showLoader" class="text-base-foreground">
{{ $t('g.loading') }}...
</span>
<span v-else>
@@ -117,6 +109,7 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
@@ -126,7 +119,6 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { cn } from '@/utils/tailwindUtil'
interface ImagePreviewProps {
/** Array of image URLs to display */
@@ -149,10 +141,19 @@ const currentIndex = ref(0)
const isHovered = ref(false)
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const isLoading = ref(false)
const showLoader = ref(false)
const currentImageEl = ref<HTMLImageElement>()
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
showLoader.value = true
},
250,
// Make sure it doesnt run on component mount
{ immediate: false }
)
// Computed values
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
@@ -169,17 +170,19 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
imageError.value = false
isLoading.value = newUrls.length > 0
if (newUrls.length > 0) startDelayedLoader()
},
{ deep: true }
{ deep: true, immediate: true }
)
// Event handlers
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
isLoading.value = false
stopDelayedLoader()
showLoader.value = false
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
@@ -187,7 +190,8 @@ const handleImageLoad = (event: Event) => {
}
const handleImageError = () => {
isLoading.value = false
stopDelayedLoader()
showLoader.value = false
imageError.value = true
actualDimensions.value = null
}
@@ -230,8 +234,7 @@ const setCurrentIndex = (index: number) => {
if (currentIndex.value === index) return
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
isLoading.value = true
startDelayedLoader()
imageError.value = false
}
}

View File

@@ -11,9 +11,10 @@
'bg-component-node-background lg-node absolute pb-1',
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
'rounded-2xl touch-none flex flex-col',
shapeClass,
'touch-none flex flex-col',
'border-1 border-solid border-component-node-border',
// hover (only when node should handle events)
// hover (only when node should handle events)1
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
'outline-transparent outline-2',
@@ -21,9 +22,9 @@
outlineClass,
cursorClass,
{
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
[`${beforeShapeClass} before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0`]:
bypassed,
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
},
@@ -140,7 +141,8 @@ import { st } from '@/i18n'
import {
LGraphCanvas,
LGraphEventMode,
LiteGraph
LiteGraph,
RenderShape
} from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -383,6 +385,28 @@ const cursorClass = computed(() => {
)
})
const shapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'rounded-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
default:
return 'rounded-2xl'
}
})
const beforeShapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'before:rounded-none'
case RenderShape.CARD:
return 'before:rounded-tl-2xl before:rounded-br-2xl before:rounded-tr-none before:rounded-bl-none'
default:
return 'before:rounded-2xl'
}
})
// Event handlers
const handleCollapse = () => {
handleNodeCollapse(nodeData.id, !isCollapsed.value)

View File

@@ -6,9 +6,9 @@
v-else
:class="
cn(
'lg-node-header py-2 pl-2 pr-3 text-sm rounded-t-2xl w-full min-w-0',
'lg-node-header py-2 pl-2 pr-3 text-sm w-full min-w-0',
'text-node-component-header bg-node-component-header-surface',
collapsed && 'rounded-2xl'
headerShapeClass
)
"
:style="headerStyle"
@@ -39,7 +39,15 @@
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<div v-if="isApiNode" class="icon-[lucide--dollar-sign] size-4" />
<div
v-if="isApiNode"
:class="
flags.subscriptionTiersEnabled
? 'icon-[lucide--component]'
: 'icon-[lucide--dollar-sign]'
"
class="size-4"
/>
<!-- Node Title -->
<div
@@ -76,13 +84,16 @@
v-tooltip.top="enterSubgraphTooltipConfig"
type="transparent"
data-testid="subgraph-enter-button"
class="size-5"
class="ml-2 text-node-component-header h-5"
@click.stop="handleEnterSubgraph"
@dblclick.stop
>
<i
class="icon-[lucide--picture-in-picture] size-5 text-node-component-header-icon"
></i>
<div
class="min-w-max rounded-sm bg-node-component-surface px-1 py-0.5 text-xs flex items-center gap-1"
>
{{ $t('g.edit') }}
<i class="icon-[lucide--scaling] size-5"></i>
</div>
</IconButton>
</div>
</div>
@@ -96,8 +107,9 @@ import IconButton from '@/components/button/IconButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
@@ -126,6 +138,8 @@ const emit = defineEmits<{
'enter-subgraph': []
}>()
const { flags } = useFeatureFlags()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
@@ -202,6 +216,28 @@ const nodeBadges = computed<NodeBadgeProps[]>(() =>
)
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
const headerShapeClass = computed(() => {
if (collapsed) {
switch (nodeData?.shape) {
case RenderShape.BOX:
return 'rounded-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
default:
return 'rounded-2xl'
}
}
switch (nodeData?.shape) {
case RenderShape.BOX:
return 'rounded-t-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-tr-none'
default:
return 'rounded-t-2xl'
}
})
// Subgraph detection
const isSubgraphNode = computed(() => {
if (!nodeData?.id) return false

View File

@@ -26,6 +26,8 @@ export function useNodePointerInteractions(
return true
}
let hasDraggingStarted = false
const startPosition = ref({ x: 0, y: 0 })
const DRAG_THRESHOLD = 3 // pixels
@@ -57,7 +59,7 @@ export function useNodePointerInteractions(
startPosition.value = { x: event.clientX, y: event.clientY }
startDrag(event, nodeId)
safeDragStart(event, nodeId)
}
function onPointermove(event: PointerEvent) {
@@ -78,7 +80,7 @@ export function useNodePointerInteractions(
if (lmbDown && multiSelect && !layoutStore.isDraggingVueNodes.value) {
layoutStore.isDraggingVueNodes.value = true
handleNodeSelect(event, nodeId)
startDrag(event, nodeId)
safeDragStart(event, nodeId)
return
}
// Check if we should start dragging (pointer moved beyond threshold)
@@ -102,6 +104,14 @@ export function useNodePointerInteractions(
layoutStore.isDraggingVueNodes.value = false
}
function safeDragStart(event: PointerEvent, nodeId: string) {
try {
startDrag(event, nodeId)
} finally {
hasDraggingStarted = true
}
}
function safeDragEnd(event: PointerEvent) {
try {
const nodeId = toValue(nodeIdRef)
@@ -109,6 +119,7 @@ export function useNodePointerInteractions(
} catch (error) {
console.error('Error during endDrag:', error)
} finally {
hasDraggingStarted = false
cleanupDragState()
}
}
@@ -123,9 +134,12 @@ export function useNodePointerInteractions(
}
const wasDragging = layoutStore.isDraggingVueNodes.value
if (wasDragging) {
if (hasDraggingStarted || wasDragging) {
safeDragEnd(event)
return
if (wasDragging) {
return
}
}
// Skip selection handling for right-click (button 2) - context menu handles its own selection

View File

@@ -1,6 +1,7 @@
import { storeToRefs } from 'pinia'
import { toValue } from 'vue'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -13,13 +14,14 @@ import type {
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import { createSharedComposable } from '@vueuse/core'
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
function useNodeDragIndividual() {
const mutations = useLayoutMutations()
const { selectedNodeIds } = storeToRefs(useCanvasStore())
const { selectedNodeIds, selectedItems } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available
const transformState = useTransformState()
@@ -37,6 +39,10 @@ function useNodeDragIndividual() {
let rafId: number | null = null
let stopShiftSync: (() => void) | null = null
// For groups: track the last applied canvas delta to compute frame delta
let lastCanvasDelta: Point | null = null
let selectedGroups: LGraphGroup[] | null = null
function startDrag(event: PointerEvent, nodeId: NodeId) {
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
if (!layout) return
@@ -67,6 +73,10 @@ function useNodeDragIndividual() {
otherSelectedNodesStartPositions = null
}
// Capture selected groups (filter from selectedItems which only contains selected items)
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
lastCanvasDelta = { x: 0, y: 0 }
mutations.setSource(LayoutSource.Vue)
}
@@ -127,6 +137,21 @@ function useNodeDragIndividual() {
mutations.moveNode(otherNodeId, newOtherPosition)
}
}
// Move selected groups using frame delta (difference from last frame)
// This matches LiteGraph's behavior which uses delta-based movement
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
const frameDelta = {
x: canvasDelta.x - lastCanvasDelta.x,
y: canvasDelta.y - lastCanvasDelta.y
}
for (const group of selectedGroups) {
group.move(frameDelta.x, frameDelta.y, true)
}
}
lastCanvasDelta = canvasDelta
})
}
@@ -195,6 +220,8 @@ function useNodeDragIndividual() {
dragStartPos = null
dragStartMouse = null
otherSelectedNodesStartPositions = null
selectedGroups = null
lastCanvasDelta = null
// Stop tracking shift key state
stopShiftSync?.()

View File

@@ -386,11 +386,12 @@ export const useDialogService = () => {
return dialogStore.showDialog({
key: 'top-up-credits',
component: TopUpCreditsDialogContent,
headerComponent: ComfyOrgHeader,
props: options,
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-3!' }
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0!' }
}
}
})

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