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
93 changed files with 3032 additions and 2487 deletions

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')
)
])

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

@@ -1945,40 +1945,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/omni-image": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** KlingAI Create Omni-Image Task */
post: operations["klingCreateOmniImage"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/omni-image/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** KlingAI Query Single Omni-Image Task */
get: operations["klingOmniImageQuerySingleTask"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/proxy/kling/v1/images/kolors-virtual-try-on": {
parameters: {
query?: never;
@@ -3910,7 +3876,7 @@ export interface components {
* @description The subscription tier level
* @enum {string}
*/
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO" | "FOUNDERS_EDITION";
SubscriptionTier: "STANDARD" | "CREATOR" | "PRO";
FeaturesResponse: {
/**
* @description The conversion rate for partner nodes
@@ -5130,71 +5096,6 @@ export interface components {
};
};
};
KlingOmniImageRequest: {
/**
* @description Model Name
* @default kling-image-o1
* @enum {string}
*/
model_name: "kling-image-o1";
/** @description Text prompt words, which can include positive and negative descriptions. Must not exceed 2,500 characters. The Omni model can achieve various capabilities through Prompt with elements and images. Specify an image in the format of <<<>>>, such as <<<image_1>>>. */
prompt: string;
/** @description Reference Image List. Supports inputting image Base64 encoding or image URL (ensure accessibility). Supported formats include .jpg/.jpeg/.png. File size cannot exceed 10MB. Width and height dimensions shall not be less than 300px, aspect ratio between 1:2.5 ~ 2.5:1. Maximum 10 images. */
image_list?: {
/** @description Image Base64 encoding or image URL (ensure accessibility) */
image?: string;
}[];
/**
* @description Image generation resolution. 1k is 1K standard, 2k is 2K high-res, 4k is 4K high-res.
* @default 1k
* @enum {string}
*/
resolution: "1k" | "2k" | "4k";
/**
* @description Number of generated images. Value range [1,9].
* @default 1
*/
n: number;
/**
* @description Aspect ratio of the generated images (width:height). auto is to intelligently generate images based on incoming content.
* @default auto
* @enum {string}
*/
aspect_ratio: "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "21:9" | "auto";
/**
* Format: uri
* @description The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes.
*/
callback_url?: string;
/** @description Customized Task ID. Must be unique within a single user account. */
external_task_id?: string;
};
KlingOmniImageResponse: {
/** @description Error code */
code?: number;
/** @description Error message */
message?: string;
/** @description Request ID */
request_id?: string;
data?: {
/** @description Task ID */
task_id?: string;
task_status?: components["schemas"]["KlingTaskStatus"];
/** @description Task status information, displaying the failure reason when the task fails (such as triggering the content risk control of the platform, etc.) */
task_status_msg?: string;
task_info?: {
/** @description Customer-defined task ID */
external_task_id?: string;
};
/** @description Task creation time, Unix timestamp in milliseconds */
created_at?: number;
/** @description Task update time, Unix timestamp in milliseconds */
updated_at?: number;
task_result?: {
images?: components["schemas"]["KlingImageResult"][];
};
};
};
KlingLipSyncInputObject: {
/** @description The ID of the video generated by Kling AI. Only supports 5-second and 10-second videos generated within the last 30 days. */
video_id?: string;
@@ -10164,7 +10065,7 @@ export interface components {
};
BytePlusImageGenerationRequest: {
/** @enum {string} */
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828" | "seedream-4-5-251128";
model: "seedream-3-0-t2i-250415" | "seededit-3-0-i2i-250628" | "seedream-4-0-250828";
/** @description Text description for image generation or transformation */
prompt: string;
/**
@@ -10269,10 +10170,10 @@ export interface components {
};
BytePlusVideoGenerationRequest: {
/**
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-pro-fast-251015, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
* @description The ID of the model to call. Available models include seedance-1-0-pro-250528, seedance-1-0-lite-t2v-250428, seedance-1-0-lite-i2v-250428
* @enum {string}
*/
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428" | "seedance-1-0-pro-fast-251015";
model: "seedance-1-0-pro-250528" | "seedance-1-0-lite-t2v-250428" | "seedance-1-0-lite-i2v-250428";
/** @description The input content for the model to generate a video */
content: components["schemas"]["BytePlusVideoGenerationContent"][];
/**
@@ -14046,15 +13947,6 @@ export interface operations {
"application/json": components["schemas"]["Node"];
};
};
/** @description Redirect to node with normalized name match */
302: {
headers: {
/** @description URL of the node with the correct ID */
Location?: string;
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
@@ -18453,198 +18345,6 @@ export interface operations {
};
};
};
klingCreateOmniImage: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Create task for generating omni-image */
requestBody: {
content: {
"application/json": components["schemas"]["KlingOmniImageRequest"];
};
};
responses: {
/** @description Successful response (Request successful) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingOmniImageResponse"];
};
};
/** @description Invalid request parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Authentication failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Unauthorized access to requested resource */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Account exception or Rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Service temporarily unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Server timeout */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
};
};
klingOmniImageQuerySingleTask: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Task ID or External Task ID. Can query by either task_id (generated by system) or external_task_id (customized task ID) */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful response (Request successful) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingOmniImageResponse"];
};
};
/** @description Invalid request parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Authentication failed */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Unauthorized access to requested resource */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Account exception or Rate limit exceeded */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Service temporarily unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
/** @description Server timeout */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["KlingErrorResponse"];
};
};
};
};
klingVirtualTryOnQueryTaskList: {
parameters: {
query?: {

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

@@ -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

@@ -64,8 +64,7 @@ const formattedCreditsOnly = computed(() => {
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
locale: locale.value
})
return amount
})

View File

@@ -3,7 +3,7 @@
<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-base-foreground m-0">
<h1 class="text-2xl font-semibold text-white m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
@@ -62,7 +62,7 @@
severity="primary"
:label="$t('credits.topUp.buy')"
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
:pt="{ label: { class: 'text-primary-foreground' } }"
:pt="{ label: { class: 'text-white' } }"
@click="handleBuy"
/>
</div>
@@ -122,7 +122,11 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd } from '@/base/credits/comfyCredits'
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'
@@ -152,7 +156,7 @@ const { formattedRenewalDate } = useSubscription()
// Use feature flag to determine design - defaults to true (new design)
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
const { t } = useI18n()
const { t, locale } = useI18n()
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const toast = useToast()
@@ -187,6 +191,19 @@ const handleBuy = async () => {
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)

View File

@@ -8,10 +8,10 @@
]"
@click="$emit('select')"
>
<span class="text-base font-bold text-base-foreground">
<span class="text-base font-bold text-white">
{{ formattedCredits }}
</span>
<span class="text-sm font-normal text-muted-foreground">
<span class="text-sm font-normal text-white">
{{ description }}
</span>
</div>

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

@@ -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

@@ -21,21 +21,12 @@
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
<p v-if="subscriptionTierName" class="my-0 truncate text-sm text-muted">
{{ subscriptionTierName }}
</p>
</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" />
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="flex-1"
/>
<span v-else class="text-base font-normal text-base-foreground flex-1">{{
<span class="text-base font-normal text-base-foreground flex-1">{{
formattedBalance
}}</span>
<Button
@@ -48,15 +39,14 @@
/>
</div>
<div v-else class="flex justify-center px-4">
<SubscribeButton
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
<SubscribeButton
v-else
class="mx-4"
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
<!-- Credits info row -->
<div
@@ -131,7 +121,6 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -163,8 +152,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const dialogService = useDialogService()
const { isActiveSubscription, subscriptionTierName, fetchStatus } =
useSubscription()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { flags } = useFeatureFlags()
const { locale } = useI18n()

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

@@ -40,12 +40,12 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.0715/second'
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value)
// If duration is 0 or NaN, don't fall back to 5 seconds - just use 0
const validDuration = isNaN(duration) ? 5 : duration
const cost = (0.0715 * validDuration).toFixed(2)
const cost = (0.05 * validDuration).toFixed(2)
return `$${cost}/Run`
}
@@ -377,11 +377,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
(w) => w.name === 'turbo'
) as IComboWidget
if (!numImagesWidget) return '$0.03-0.09 x num_images/Run'
if (!numImagesWidget) return '$0.02-0.06 x num_images/Run'
const numImages = Number(numImagesWidget.value) || 1
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
const basePrice = turbo ? 0.0286 : 0.0858
const basePrice = turbo ? 0.02 : 0.06
const cost = (basePrice * numImages).toFixed(2)
return `$${cost}/Run`
}
@@ -395,11 +395,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
(w) => w.name === 'turbo'
) as IComboWidget
if (!numImagesWidget) return '$0.07-0.11 x num_images/Run'
if (!numImagesWidget) return '$0.05-0.08 x num_images/Run'
const numImages = Number(numImagesWidget.value) || 1
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
const basePrice = turbo ? 0.0715 : 0.1144
const basePrice = turbo ? 0.05 : 0.08
const cost = (basePrice * numImages).toFixed(2)
return `$${cost}/Run`
}
@@ -420,29 +420,29 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
characterInput.link != null
if (!renderingSpeedWidget)
return '$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)'
return '$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
const numImages = Number(numImagesWidget?.value) || 1
let basePrice = 0.0858 // default balanced price
let basePrice = 0.06 // default balanced price
const renderingSpeed = String(renderingSpeedWidget.value)
if (renderingSpeed.toLowerCase().includes('quality')) {
if (hasCharacter) {
basePrice = 0.286
basePrice = 0.2
} else {
basePrice = 0.1287
basePrice = 0.09
}
} else if (renderingSpeed.toLowerCase().includes('default')) {
if (hasCharacter) {
basePrice = 0.2145
basePrice = 0.15
} else {
basePrice = 0.0858
basePrice = 0.06
}
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
if (hasCharacter) {
basePrice = 0.143
basePrice = 0.1
} else {
basePrice = 0.0429
basePrice = 0.03
}
}
@@ -755,7 +755,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget
if (!modelWidget || !resolutionWidget || !durationWidget) {
return '$0.20-16.40/Run (varies with model, resolution & duration)'
return '$0.14-11.47/Run (varies with model, resolution & duration)'
}
const model = String(modelWidget.value)
@@ -764,33 +764,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
if (model.includes('ray-flash-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$3.13/Run'
if (resolution.includes('1080p')) return '$0.79/Run'
if (resolution.includes('720p')) return '$0.34/Run'
if (resolution.includes('540p')) return '$0.20/Run'
if (resolution.includes('4k')) return '$2.19/Run'
if (resolution.includes('1080p')) return '$0.55/Run'
if (resolution.includes('720p')) return '$0.24/Run'
if (resolution.includes('540p')) return '$0.14/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$5.65/Run'
if (resolution.includes('1080p')) return '$1.42/Run'
if (resolution.includes('720p')) return '$0.61/Run'
if (resolution.includes('540p')) return '$0.36/Run'
if (resolution.includes('4k')) return '$3.95/Run'
if (resolution.includes('1080p')) return '$0.99/Run'
if (resolution.includes('720p')) return '$0.43/Run'
if (resolution.includes('540p')) return '$0.252/Run'
}
} else if (model.includes('ray-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$9.11/Run'
if (resolution.includes('1080p')) return '$2.27/Run'
if (resolution.includes('720p')) return '$1.02/Run'
if (resolution.includes('540p')) return '$0.57/Run'
if (resolution.includes('4k')) return '$6.37/Run'
if (resolution.includes('1080p')) return '$1.59/Run'
if (resolution.includes('720p')) return '$0.71/Run'
if (resolution.includes('540p')) return '$0.40/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$16.40/Run'
if (resolution.includes('1080p')) return '$4.10/Run'
if (resolution.includes('720p')) return '$1.83/Run'
if (resolution.includes('540p')) return '$1.03/Run'
if (resolution.includes('4k')) return '$11.47/Run'
if (resolution.includes('1080p')) return '$2.87/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
}
} else if (model.includes('ray-1-6')) {
return '$0.50/Run'
} else if (model.includes('ray-1.6')) {
return '$0.35/Run'
}
return '$0.79/Run'
return '$0.55/Run'
}
},
LumaVideoNode: {
@@ -806,7 +806,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget
if (!modelWidget || !resolutionWidget || !durationWidget) {
return '$0.20-16.40/Run (varies with model, resolution & duration)'
return '$0.14-11.47/Run (varies with model, resolution & duration)'
}
const model = String(modelWidget.value)
@@ -815,33 +815,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
if (model.includes('ray-flash-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$3.13/Run'
if (resolution.includes('1080p')) return '$0.79/Run'
if (resolution.includes('720p')) return '$0.34/Run'
if (resolution.includes('540p')) return '$0.20/Run'
if (resolution.includes('4k')) return '$2.19/Run'
if (resolution.includes('1080p')) return '$0.55/Run'
if (resolution.includes('720p')) return '$0.24/Run'
if (resolution.includes('540p')) return '$0.14/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$5.65/Run'
if (resolution.includes('1080p')) return '$1.42/Run'
if (resolution.includes('720p')) return '$0.61/Run'
if (resolution.includes('540p')) return '$0.36/Run'
if (resolution.includes('4k')) return '$3.95/Run'
if (resolution.includes('1080p')) return '$0.99/Run'
if (resolution.includes('720p')) return '$0.43/Run'
if (resolution.includes('540p')) return '$0.252/Run'
}
} else if (model.includes('ray-2')) {
if (duration.includes('5s')) {
if (resolution.includes('4k')) return '$9.11/Run'
if (resolution.includes('1080p')) return '$2.27/Run'
if (resolution.includes('720p')) return '$1.02/Run'
if (resolution.includes('540p')) return '$0.57/Run'
if (resolution.includes('4k')) return '$6.37/Run'
if (resolution.includes('1080p')) return '$1.59/Run'
if (resolution.includes('720p')) return '$0.71/Run'
if (resolution.includes('540p')) return '$0.40/Run'
} else if (duration.includes('9s')) {
if (resolution.includes('4k')) return '$16.40/Run'
if (resolution.includes('1080p')) return '$4.10/Run'
if (resolution.includes('720p')) return '$1.83/Run'
if (resolution.includes('540p')) return '$1.03/Run'
if (resolution.includes('4k')) return '$11.47/Run'
if (resolution.includes('1080p')) return '$2.87/Run'
if (resolution.includes('720p')) return '$1.28/Run'
if (resolution.includes('540p')) return '$0.72/Run'
}
} else if (model.includes('ray-1-6')) {
return '$0.50/Run'
return '$0.35/Run'
}
return '$0.79/Run'
return '$0.55/Run'
}
},
MinimaxImageToVideoNode: {
@@ -1323,18 +1323,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget
if (!modelWidget || !aspectRatioWidget) {
return '$0.0064-0.026/Run (varies with model & aspect ratio)'
return '$0.0045-0.0182/Run (varies with model & aspect ratio)'
}
const model = String(modelWidget.value)
if (model.includes('photon-flash-1')) {
return '$0.0027/Run'
return '$0.0019/Run'
} else if (model.includes('photon-1')) {
return '$0.0104/Run'
return '$0.0073/Run'
}
return '$0.0246/Run'
return '$0.0172/Run'
}
},
LumaImageModifyNode: {
@@ -1344,18 +1344,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget
if (!modelWidget) {
return '$0.0027-0.0104/Run (varies with model)'
return '$0.0019-0.0073/Run (varies with model)'
}
const model = String(modelWidget.value)
if (model.includes('photon-flash-1')) {
return '$0.0027/Run'
return '$0.0019/Run'
} else if (model.includes('photon-1')) {
return '$0.0104/Run'
return '$0.0073/Run'
}
return '$0.0246/Run'
return '$0.0172/Run'
}
},
MoonvalleyTxt2VideoNode: {
@@ -1417,7 +1417,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
// Runway nodes - using actual node names from ComfyUI
RunwayTextToImageNode: {
displayPrice: '$0.11/Run'
displayPrice: '$0.08/Run'
},
RunwayImageToVideoNodeGen3a: {
displayPrice: calculateRunwayDurationPrice

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

@@ -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

@@ -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
})
}
}
/**

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

@@ -200,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",
@@ -466,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": {
@@ -747,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?",
@@ -763,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",
@@ -1042,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",
@@ -1843,6 +1867,8 @@
"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"
@@ -1868,8 +1894,8 @@
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "/ month",
"usdPerMonth": "USD / month",
"renewsDate": "Renews {date}",
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
@@ -1882,7 +1908,7 @@
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsRemainingThisMonth": "Credits remaining this month",
"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",
@@ -1898,30 +1924,25 @@
},
"tiers": {
"founder": {
"name": "Founder's Edition",
"name": "Founder's Edition Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "5,460",
"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"
"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",
"name": "Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "4,200",
"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",
"videoEstimate": "120"
"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": {
@@ -1934,22 +1955,19 @@
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "288"
"customLoRAsLabel": "Import your own LoRAs"
}
},
"pro": {
"name": "Pro",
"price": "100.00",
"benefits": {
"monthlyCredits": "21,100",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "1 hr",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimate": "815"
"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"
}
}
},
@@ -1972,31 +1990,7 @@
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"partnerNodesCredits": "Partner nodes pricing",
"mostPopular": "Most popular",
"currentPlan": "Current Plan",
"subscribeTo": "Subscribe to {plan}",
"monthlyCreditsLabel": "Monthly credits",
"maxDurationLabel": "Max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs",
"videoEstimateLabel": "Approx. amount of 5s videos generated with Wan Fun Control template",
"videoEstimateHelp": "What is this?",
"videoEstimateExplanation": "These estimates are based on the Wan Fun Control template for 5-second videos.",
"videoEstimateTryTemplate": "Try the Wan Fun Control template →",
"upgradeTo": "Upgrade to {plan}",
"changeTo": "Change to {plan}",
"credits": {
"standard": "4,200",
"creator": "7,400",
"pro": "21,100"
},
"maxDuration": {
"standard": "30 min",
"creator": "30 min",
"pro": "1 hr"
}
"partnerNodesCredits": "Partner nodes pricing"
},
"userSettings": {
"title": "My Account Settings",
@@ -2040,6 +2034,7 @@
},
"whatsNewPopup": {
"learnMore": "Learn more",
"later": "Later",
"noReleaseNotes": "No release notes available."
},
"breadcrumbsMenu": {
@@ -2195,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}...",
"connectionError": "Please check your connection and try again",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"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.",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com/models\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models</a> are supported at the moment",
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
"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",
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/models/10706/luisap-z-image-and-qwen-pixel-art-refiner?modelVersionId=2225295</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",
"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 successfully 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",
"connectionError": "Please check your connection and try again",
"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",
"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",
"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>",
"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?",
"uploadSuccess": "Model imported successfully!",
"ariaLabel": {
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
@@ -2384,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

@@ -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

@@ -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

@@ -1,336 +0,0 @@
<template>
<div class="flex flex-row items-stretch gap-6">
<div
v-for="tier in tiers"
:key="tier.id"
class="flex-1 flex flex-col rounded-2xl border border-interface-stroke bg-interface-panel-surface shadow-[0_0_12px_rgba(0,0,0,0.1)]"
>
<div class="flex flex-col gap-6 p-8">
<div class="flex flex-row items-center gap-2">
<span
class="font-inter text-base font-bold leading-normal text-base-foreground"
>
{{ tier.name }}
</span>
<div
v-if="tier.isPopular"
class="rounded-full bg-background px-1 text-xs font-semibold uppercase tracking-wide text-foreground h-[13px] leading-[13px]"
>
{{ t('subscription.mostPopular') }}
</div>
</div>
<div class="flex flex-row items-baseline gap-2">
<span
class="font-inter text-[32px] font-semibold leading-normal text-base-foreground"
>
${{ tier.price }}
</span>
<span
class="font-inter text-base font-normal leading-normal text-base-foreground"
>
{{ t('subscription.usdPerMonth') }}
</span>
</div>
</div>
<div class="flex flex-col gap-4 px-8 pb-0 flex-1">
<div class="flex flex-row items-center justify-between">
<span
class="font-inter text-sm font-normal leading-normal text-muted-foreground"
>
{{ t('subscription.monthlyCreditsLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.credits }}
</span>
</div>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.maxDurationLabel') }}
</span>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.maxDuration }}
</span>
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.gpuLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.addCreditsLabel') }}
</span>
<i class="pi pi-check text-xs text-success-foreground" />
</div>
<div class="flex flex-row items-center justify-between">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.customLoRAsLabel') }}
</span>
<i
v-if="tier.customLoRAs"
class="pi pi-check text-xs text-success-foreground"
/>
<i v-else class="pi pi-times text-xs text-muted-foreground" />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col gap-2">
<span class="text-sm font-normal text-muted-foreground">
{{ t('subscription.videoEstimateLabel') }}
</span>
<div class="flex flex-row items-center gap-2 opacity-50">
<i
class="pi pi-question-circle text-xs text-muted-foreground"
/>
<span
class="text-sm font-normal text-muted-foreground cursor-pointer hover:text-base-foreground"
@click="togglePopover"
>
{{ t('subscription.videoEstimateHelp') }}
</span>
</div>
</div>
<span
class="font-inter text-sm font-bold leading-normal text-base-foreground"
>
{{ tier.videoEstimate }}
</span>
</div>
</div>
</div>
<div class="flex flex-col p-8">
<Button
:label="getButtonLabel(tier)"
:severity="getButtonSeverity(tier)"
:disabled="isLoading || isCurrentPlan(tier.key)"
:loading="loadingTier === tier.key"
class="h-10 w-full"
:pt="{
label: {
class:
'font-inter text-sm font-bold leading-normal text-primary-foreground'
}
}"
@click="() => handleSubscribe(tier.key)"
/>
</div>
</div>
</div>
<!-- Video Estimate Help Popover -->
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class:
'rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-lg p-4 max-w-xs'
}
}"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-base-foreground">
{{ t('subscription.videoEstimateExplanation') }}
</p>
<a
href="https://cloud.comfy.org/?template=video_wan2_2_14B_fun_camera"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-azure-600 hover:text-azure-400 underline"
>
{{ t('subscription.videoEstimateTryTemplate') }}
</a>
</div>
</Popover>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
type TierKey = 'standard' | 'creator' | 'pro'
interface PricingTierConfig {
id: SubscriptionTier
key: TierKey
name: string
price: string
credits: string
maxDuration: string
customLoRAs: boolean
videoEstimate: string
isPopular?: boolean
}
const TIER_TO_KEY: Record<SubscriptionTier, TierKey> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'standard'
}
const tiers: PricingTierConfig[] = [
{
id: 'STANDARD',
key: 'standard',
name: t('subscription.tiers.standard.name'),
price: t('subscription.tiers.standard.price'),
credits: t('subscription.credits.standard'),
maxDuration: t('subscription.maxDuration.standard'),
customLoRAs: false,
videoEstimate: t('subscription.tiers.standard.benefits.videoEstimate'),
isPopular: false
},
{
id: 'CREATOR',
key: 'creator',
name: t('subscription.tiers.creator.name'),
price: t('subscription.tiers.creator.price'),
credits: t('subscription.credits.creator'),
maxDuration: t('subscription.maxDuration.creator'),
customLoRAs: true,
videoEstimate: t('subscription.tiers.creator.benefits.videoEstimate'),
isPopular: true
},
{
id: 'PRO',
key: 'pro',
name: t('subscription.tiers.pro.name'),
price: t('subscription.tiers.pro.price'),
credits: t('subscription.credits.pro'),
maxDuration: t('subscription.maxDuration.pro'),
customLoRAs: true,
videoEstimate: t('subscription.tiers.pro.benefits.videoEstimate'),
isPopular: false
}
]
const { getAuthHeader } = useFirebaseAuthStore()
const { isActiveSubscription, subscriptionTier } = useSubscription()
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)
const loadingTier = ref<TierKey | null>(null)
const popover = ref()
const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
)
const isCurrentPlan = (tierKey: TierKey): boolean =>
currentTierKey.value === tierKey
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
const getButtonLabel = (tier: PricingTierConfig): string => {
if (isCurrentPlan(tier.key)) return t('subscription.currentPlan')
if (!isActiveSubscription.value)
return t('subscription.subscribeTo', { plan: tier.name })
return t('subscription.changeTo', { plan: tier.name })
}
const getButtonSeverity = (tier: PricingTierConfig): 'primary' | 'secondary' =>
isCurrentPlan(tier.key)
? 'secondary'
: tier.key === 'creator'
? 'primary'
: 'secondary'
const initiateCheckout = async (tierKey: TierKey) => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${tierKey}`,
{
method: 'POST',
headers: { ...authHeader, 'Content-Type': 'application/json' }
}
)
if (!response.ok) {
let errorMessage = 'Failed to initiate checkout'
try {
const errorData = await response.json()
errorMessage = errorData.message || errorMessage
} catch {
// If JSON parsing fails, try to get text response or use HTTP status
try {
const errorText = await response.text()
errorMessage =
errorText || `HTTP ${response.status} ${response.statusText}`
} catch {
errorMessage = `HTTP ${response.status} ${response.statusText}`
}
}
throw new FirebaseAuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorMessage
})
)
}
return await response.json()
}
const handleSubscribe = wrapWithErrorHandlingAsync(async (tierKey: TierKey) => {
if (!isCloud || isLoading.value || isCurrentPlan(tierKey)) return
isLoading.value = true
loadingTier.value = tierKey
try {
if (isActiveSubscription.value) {
await accessBillingPortal()
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {
window.open(response.checkout_url, '_blank')
}
}
} finally {
isLoading.value = false
loadingTier.value = null
}
}, reportError)
</script>

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

@@ -21,7 +21,7 @@
<div class="flex items-center justify-between">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
{{ tierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
@@ -138,6 +138,19 @@
>
{{ $t('subscription.creditsRemainingThisMonth') }}
</div>
<Button
v-tooltip="refreshTooltip"
icon="pi pi-question-circle"
text
rounded
size="small"
class="h-4 w-4 shrink-0"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
}
}"
/>
</div>
</div>
<div class="flex items-center gap-4">
@@ -340,23 +353,8 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
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 type { components } from '@/types/comfyRegistryTypes'
import { cn } from '@/utils/tailwindUtil'
type SubscriptionTier = components['schemas']['SubscriptionTier']
/** Maps API subscription tier values to i18n translation keys */
const TIER_TO_I18N_KEY = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'founder'
} as const satisfies Record<SubscriptionTier, string>
type TierKey = (typeof TIER_TO_I18N_KEY)[SubscriptionTier]
const DEFAULT_TIER_KEY: TierKey = 'standard'
const { buildDocsUrl } = useExternalLink()
const { t } = useI18n()
@@ -365,19 +363,14 @@ const {
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
handleInvoiceHistory
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_I18N_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() => t(`subscription.tiers.${tierKey.value}.price`))
// 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'
@@ -389,49 +382,38 @@ interface Benefit {
value?: string
}
const BENEFITS_BY_TIER: Record<
TierKey,
ReadonlyArray<Omit<Benefit, 'label' | 'value'>>
> = {
standard: [
{ key: 'monthlyCredits', type: 'metric' },
{ key: 'maxDuration', type: 'metric' },
{ key: 'gpu', type: 'feature' },
{ key: 'addCredits', type: 'feature' }
],
creator: [
{ key: 'monthlyCredits', type: 'metric' },
{ key: 'maxDuration', type: 'metric' },
{ key: 'gpu', type: 'feature' },
{ key: 'addCredits', type: 'feature' },
{ key: 'customLoRAs', type: 'feature' }
],
pro: [
{ key: 'monthlyCredits', type: 'metric' },
{ key: 'maxDuration', type: 'metric' },
{ key: 'gpu', type: 'feature' },
{ key: 'addCredits', type: 'feature' },
{ key: 'customLoRAs', type: 'feature' }
],
founder: [
{ key: 'monthlyCredits', type: 'metric' },
{ key: 'maxDuration', type: 'metric' },
{ key: 'gpu', type: 'feature' },
{ key: 'addCredits', type: 'feature' }
]
}
const tierBenefits = computed(() => {
const key = tierKey.value
const benefitConfig = BENEFITS_BY_TIER[key]
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 benefitConfig.map((config) => ({
...config,
...(config.type === 'metric' && {
value: t(`subscription.tiers.${key}.benefits.${config.key}`)
}),
label: t(`subscription.tiers.${key}.benefits.${config.key}Label`)
}))
return baseBenefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
@@ -439,6 +421,7 @@ const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
const {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="showCustomPricingTable"
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
@@ -32,7 +32,7 @@
/>
</div>
<PricingTable class="flex-1" />
<StripePricingTable class="flex-1" />
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center">
@@ -46,7 +46,7 @@
severity="secondary"
icon="pi pi-comments"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
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>
@@ -56,7 +56,7 @@
severity="secondary"
icon="pi pi-external-link"
icon-pos="right"
class="h-6 p-1 text-sm text-text-secondary hover:text-base-foreground"
class="h-6 p-1 text-sm text-text-secondary hover:text-white"
@click="handleViewEnterprise"
/>
</div>
@@ -138,8 +138,8 @@ import Button from 'primevue/button'
import { computed, onBeforeUnmount, watch } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import PricingTable from '@/platform/cloud/subscription/components/PricingTable.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'
@@ -155,30 +155,27 @@ const emit = defineEmits<{
close: [subscribed: boolean]
}>()
const { fetchStatus, isActiveSubscription } = useSubscription()
// Legacy price for non-tier flow with locale-aware formatting
const formattedMonthlyPrice = new Intl.NumberFormat(
navigator.language || 'en-US',
{
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
).format(MONTHLY_SUBSCRIPTION_PRICE)
const { formattedMonthlyPrice, fetchStatus, isActiveSubscription } =
useSubscription()
const { featureFlag } = useFeatureFlags()
const subscriptionTiersEnabled = featureFlag(
'subscription_tiers_enabled',
false
)
const commandStore = useCommandStore()
const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
const showCustomPricingTable = computed(
() => isCloud && window.__CONFIG__?.subscription_required
const showStripePricingTable = computed(
() =>
subscriptionTiersEnabled.value &&
isCloud &&
window.__CONFIG__?.subscription_required
)
const POLL_INTERVAL_MS = 3000
const MAX_POLL_ATTEMPTS = 3
const MAX_POLL_DURATION_MS = 5 * 60 * 1000
let pollInterval: number | null = null
let pollAttempts = 0
let pollStartTime = 0
const stopPolling = () => {
if (pollInterval) {
@@ -189,44 +186,35 @@ const stopPolling = () => {
const startPolling = () => {
stopPolling()
pollAttempts = 0
pollStartTime = Date.now()
const poll = async () => {
try {
await fetchStatus()
pollAttempts++
if (pollAttempts >= MAX_POLL_ATTEMPTS) {
stopPolling()
}
} catch (error) {
console.error(
'[SubscriptionDialog] Failed to poll subscription status',
error
)
stopPolling()
}
}
void poll()
pollInterval = window.setInterval(() => {
if (Date.now() - pollStartTime > MAX_POLL_DURATION_MS) {
stopPolling()
return
}
void poll()
}, POLL_INTERVAL_MS)
}
const handleWindowFocus = () => {
if (showCustomPricingTable.value) {
startPolling()
}
}
watch(
showCustomPricingTable,
showStripePricingTable,
(enabled) => {
if (enabled) {
window.addEventListener('focus', handleWindowFocus)
startPolling()
} else {
window.removeEventListener('focus', handleWindowFocus)
stopPolling()
}
},
@@ -236,7 +224,7 @@ watch(
watch(
() => isActiveSubscription.value,
(isActive) => {
if (isActive && showCustomPricingTable.value) {
if (isActive && showStripePricingTable.value) {
emit('close', true)
}
}
@@ -271,7 +259,6 @@ const handleViewEnterprise = () => {
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('focus', handleWindowFocus)
})
</script>

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

@@ -5,6 +5,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -13,24 +14,17 @@ import {
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { useDialogService } from '@/services/dialogService'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
type CloudSubscriptionCheckoutResponse = {
checkout_url: string
}
export type CloudSubscriptionStatusResponse = NonNullable<
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
>
type SubscriptionTier = components['schemas']['SubscriptionTier']
const TIER_TO_I18N_KEY: Record<SubscriptionTier, string> = {
STANDARD: 'standard',
CREATOR: 'creator',
PRO: 'pro',
FOUNDERS_EDITION: 'founder'
export type CloudSubscriptionStatusResponse = {
is_active: boolean
subscription_id: string
renewal_date: string | null
end_date?: string | null
}
function useSubscriptionInternal() {
@@ -78,17 +72,10 @@ function useSubscriptionInternal() {
})
})
const subscriptionTier = computed(
() => subscriptionStatus.value?.subscription_tier ?? null
const formattedMonthlyPrice = computed(
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
)
const subscriptionTierName = computed(() => {
const tier = subscriptionTier.value
if (!tier) return ''
const key = TIER_TO_I18N_KEY[tier] ?? 'standard'
return t(`subscription.tiers.${key}.name`)
})
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
const fetchStatus = wrapWithErrorHandlingAsync(
@@ -240,9 +227,7 @@ function useSubscriptionInternal() {
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
formattedMonthlyPrice,
// Actions
subscribe,

View File

@@ -1,4 +1,5 @@
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -7,18 +8,30 @@ import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
const MONTHLY_CREDIT_BONUS_USD = 10
/**
* Composable for handling subscription panel actions and loading states
*/
export function useSubscriptionActions() {
const { t } = useI18n()
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchStatus } = useSubscription()
const { fetchStatus, formattedRenewalDate } = useSubscription()
const isLoadingSupport = ref(false)
const refreshTooltip = computed(() => {
const date =
formattedRenewalDate.value || t('subscription.nextBillingCycle')
return t('subscription.refreshesOn', {
monthlyCreditBonusUsd: MONTHLY_CREDIT_BONUS_USD,
date
})
})
onMounted(() => {
void handleRefresh()
})
@@ -59,6 +72,7 @@ export function useSubscriptionActions() {
return {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,

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,15 +36,19 @@ export const useSubscriptionDialog = () => {
onClose: hide
},
dialogComponentProps: {
style: 'width: min(1200px, 95vw); max-height: 90vh;',
pt: {
root: {
class: '!rounded-[32px] overflow-visible'
},
content: {
class: '!p-0 bg-transparent'
}
}
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

@@ -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

@@ -33,11 +33,9 @@ export function useTemplateUrlLoader() {
/**
* Validates parameter format to prevent path traversal and injection attacks
* Allows: letters, numbers, underscores, hyphens, and dots (for version numbers)
* Blocks: path separators (/, \), special chars that could enable injection
*/
const isValidParameter = (param: string): boolean => {
return /^[a-zA-Z0-9_.-]+$/.test(param)
return /^[a-zA-Z0-9_-]+$/.test(param)
}
/**

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"
@@ -109,7 +109,7 @@ 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'
@@ -216,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

@@ -2,16 +2,20 @@
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList class="scrollbar-hide overflow-x-auto">
<Tab v-if="hasCompatibilityIssues" value="warning" class="mr-6 p-2">
<Tab
v-if="hasCompatibilityIssues"
value="warning"
class="mr-6 p-2 font-inter"
>
<div class="flex items-center gap-1">
<span></span>
{{ importFailed ? $t('g.error') : $t('g.warning') }}
</div>
</Tab>
<Tab value="description" class="mr-6 p-2">
<Tab value="description" class="mr-6 p-2 font-inter">
{{ $t('g.description') }}
</Tab>
<Tab value="nodes" class="p-2">
<Tab value="nodes" class="p-2 font-inter">
{{ $t('g.nodes') }}
</Tab>
</TabList>

View File

@@ -73,51 +73,83 @@ function mountAssetFilterBar(props = {}) {
})
}
// Helper functions to find filters by user-facing attributes
function findFileFormatsFilter(
wrapper: ReturnType<typeof mountAssetFilterBar>
) {
return wrapper.findComponent(
'[data-component-id="asset-filter-file-formats"]'
)
}
function findBaseModelsFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
return wrapper.findComponent('[data-component-id="asset-filter-base-models"]')
}
function findOwnershipFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
return wrapper.findComponent('[data-component-id="asset-filter-ownership"]')
}
function findSortFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
return wrapper.findComponent('[data-component-id="asset-filter-sort"]')
}
describe('AssetFilterBar', () => {
describe('Filter State Management', () => {
it('handles multiple simultaneous filter changes correctly', async () => {
// Provide assets with options so filters are visible
const assets = [
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificBaseModel('sd15')
createAssetWithSpecificExtension('ckpt'),
createAssetWithSpecificBaseModel('sd15'),
createAssetWithSpecificBaseModel('sdxl')
]
const wrapper = mountAssetFilterBar({ assets })
// Update file formats
const fileFormatSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[0]
await fileFormatSelect.vm.$emit('update:modelValue', [
{ name: '.ckpt', value: 'ckpt' },
{ name: '.safetensors', value: 'safetensors' }
])
const fileFormatSelect = findFileFormatsFilter(wrapper)
const fileFormatSelectElement = fileFormatSelect.find('select')
const options = fileFormatSelectElement.findAll('option')
const ckptOption = options.find((o) => o.element.value === 'ckpt')!
const safetensorsOption = options.find(
(o) => o.element.value === 'safetensors'
)!
ckptOption.element.selected = true
safetensorsOption.element.selected = true
await fileFormatSelectElement.trigger('change')
await nextTick()
// Update base models
const baseModelSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[1]
await baseModelSelect.vm.$emit('update:modelValue', [
{ name: 'SD XL', value: 'sdxl' }
])
const baseModelSelect = findBaseModelsFilter(wrapper)
const baseModelSelectElement = baseModelSelect.find('select')
const sdxlOption = baseModelSelectElement
.findAll('option')
.find((o) => o.element.value === 'sdxl')
sdxlOption!.element.selected = true
await baseModelSelectElement.trigger('change')
await nextTick()
// Update sort
const sortSelect = wrapper.findComponent({ name: 'SingleSelect' })
await sortSelect.vm.$emit('update:modelValue', 'popular')
const sortSelect = findSortFilter(wrapper)
const sortSelectElement = sortSelect.find('select')
sortSelectElement.element.value = 'name-desc'
await sortSelectElement.trigger('change')
await nextTick()
const emitted = wrapper.emitted('filterChange')
expect(emitted).toHaveLength(3)
expect(emitted).toBeTruthy()
expect(emitted!.length).toBeGreaterThanOrEqual(3)
// Check final state
const finalState: FilterState = emitted![2][0] as FilterState
const finalState: FilterState = emitted![
emitted!.length - 1
][0] as FilterState
expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors'])
expect(finalState.baseModels).toEqual(['sdxl'])
expect(finalState.sortBy).toBe('popular')
expect(finalState.sortBy).toBe('name-desc')
})
it('ensures FilterState interface compliance', async () => {
@@ -128,12 +160,11 @@ describe('AssetFilterBar', () => {
]
const wrapper = mountAssetFilterBar({ assets })
const fileFormatSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[0]
await fileFormatSelect.vm.$emit('update:modelValue', [
{ name: '.ckpt', value: 'ckpt' }
])
const fileFormatSelect = findFileFormatsFilter(wrapper)
const fileFormatSelectElement = fileFormatSelect.find('select')
const ckptOption = fileFormatSelectElement.findAll('option')[0]
ckptOption.element.selected = true
await fileFormatSelectElement.trigger('change')
await nextTick()
@@ -165,10 +196,11 @@ describe('AssetFilterBar', () => {
const wrapper = mountAssetFilterBar({ assets })
const fileFormatSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[0]
expect(fileFormatSelect.props('options')).toEqual([
const fileFormatSelect = findFileFormatsFilter(wrapper)
const options = fileFormatSelect.findAll('option')
expect(
options.map((o) => ({ name: o.text(), value: o.element.value }))
).toEqual([
{ name: '.ckpt', value: 'ckpt' },
{ name: '.pt', value: 'pt' },
{ name: '.safetensors', value: 'safetensors' }
@@ -184,10 +216,11 @@ describe('AssetFilterBar', () => {
const wrapper = mountAssetFilterBar({ assets })
const baseModelSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[1]
expect(baseModelSelect.props('options')).toEqual([
const baseModelSelect = findBaseModelsFilter(wrapper)
const options = baseModelSelect.findAll('option')
expect(
options.map((o) => ({ name: o.text(), value: o.element.value }))
).toEqual([
{ name: 'sd15', value: 'sd15' },
{ name: 'sd35', value: 'sd35' },
{ name: 'sdxl', value: 'sdxl' }
@@ -200,26 +233,16 @@ describe('AssetFilterBar', () => {
const assets: AssetItem[] = [] // No assets = no file format options
const wrapper = mountAssetFilterBar({ assets })
const fileFormatSelects = wrapper
.findAllComponents({ name: 'MultiSelect' })
.filter(
(component) => component.props('label') === 'assetBrowser.fileFormats'
)
expect(fileFormatSelects).toHaveLength(0)
const fileFormatSelect = findFileFormatsFilter(wrapper)
expect(fileFormatSelect.exists()).toBe(false)
})
it('hides base model filter when no options available', () => {
const assets = [createAssetWithoutBaseModel()] // Asset without base model = no base model options
const wrapper = mountAssetFilterBar({ assets })
const baseModelSelects = wrapper
.findAllComponents({ name: 'MultiSelect' })
.filter(
(component) => component.props('label') === 'assetBrowser.baseModels'
)
expect(baseModelSelects).toHaveLength(0)
const baseModelSelect = findBaseModelsFilter(wrapper)
expect(baseModelSelect.exists()).toBe(false)
})
it('shows both filters when options are available', () => {
@@ -229,23 +252,106 @@ describe('AssetFilterBar', () => {
]
const wrapper = mountAssetFilterBar({ assets })
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
const fileFormatSelect = multiSelects.find(
(component) => component.props('label') === 'assetBrowser.fileFormats'
)
const baseModelSelect = multiSelects.find(
(component) => component.props('label') === 'assetBrowser.baseModels'
)
const fileFormatSelect = findFileFormatsFilter(wrapper)
const baseModelSelect = findBaseModelsFilter(wrapper)
expect(fileFormatSelect).toBeDefined()
expect(baseModelSelect).toBeDefined()
expect(fileFormatSelect.exists()).toBe(true)
expect(baseModelSelect.exists()).toBe(true)
})
it('hides both filters when no assets provided', () => {
const wrapper = mountAssetFilterBar()
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
expect(multiSelects).toHaveLength(0)
const fileFormatSelect = findFileFormatsFilter(wrapper)
const baseModelSelect = findBaseModelsFilter(wrapper)
expect(fileFormatSelect.exists()).toBe(false)
expect(baseModelSelect.exists()).toBe(false)
})
it('hides ownership filter when no mutable assets', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', true) // immutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(false)
})
it('shows ownership filter when mutable assets exist', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
})
it('shows ownership filter when mixed assets exist', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', true), // immutable
createAssetWithSpecificExtension('ckpt', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
})
it('shows ownership filter with allAssets when provided', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', true) // immutable
]
const allAssets = [
createAssetWithSpecificExtension('safetensors', true), // immutable
createAssetWithSpecificExtension('ckpt', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets, allAssets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
})
})
describe('Ownership Filter', () => {
it('emits ownership filter changes', async () => {
const assets = [
createAssetWithSpecificExtension('safetensors', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
const ownershipSelectElement = ownershipSelect.find('select')
ownershipSelectElement.element.value = 'my-models'
await ownershipSelectElement.trigger('change')
await nextTick()
const emitted = wrapper.emitted('filterChange')
expect(emitted).toBeTruthy()
const filterState = emitted![emitted!.length - 1][0] as FilterState
expect(filterState.ownership).toBe('my-models')
})
it('ownership filter defaults to "all"', async () => {
const assets = [
createAssetWithSpecificExtension('safetensors', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const sortSelect = findSortFilter(wrapper)
const sortSelectElement = sortSelect.find('select')
sortSelectElement.element.value = 'recent'
await sortSelectElement.trigger('change')
await nextTick()
const emitted = wrapper.emitted('filterChange')
const filterState = emitted![0][0] as FilterState
expect(filterState.ownership).toBe('all')
})
})
})

View File

@@ -249,7 +249,8 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: ['safetensors'],
baseModels: []
baseModels: [],
ownership: 'all'
})
await nextTick()
@@ -284,7 +285,8 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: ['SDXL']
baseModels: ['SDXL'],
ownership: 'all'
})
await nextTick()
@@ -335,7 +337,12 @@ describe('useAssetBrowser', () => {
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({ sortBy: 'name', fileFormats: [], baseModels: [] })
updateFilters({
sortBy: 'name',
fileFormats: [],
baseModels: [],
ownership: 'all'
})
await nextTick()
const names = filteredAssets.value.map((asset) => asset.name)
@@ -355,7 +362,12 @@ describe('useAssetBrowser', () => {
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({ sortBy: 'recent', fileFormats: [], baseModels: [] })
updateFilters({
sortBy: 'recent',
fileFormats: [],
baseModels: [],
ownership: 'all'
})
await nextTick()
const dates = filteredAssets.value.map((asset) => asset.created_at)
@@ -367,6 +379,92 @@ describe('useAssetBrowser', () => {
})
})
describe('Ownership filtering', () => {
it('filters by ownership - all', async () => {
const assets = [
createApiAsset({ name: 'my-model.safetensors', is_immutable: false }),
createApiAsset({
name: 'public-model.safetensors',
is_immutable: true
}),
createApiAsset({
name: 'another-my-model.safetensors',
is_immutable: false
})
]
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: [],
ownership: 'all'
})
await nextTick()
expect(filteredAssets.value).toHaveLength(3)
})
it('filters by ownership - my models only', async () => {
const assets = [
createApiAsset({ name: 'my-model.safetensors', is_immutable: false }),
createApiAsset({
name: 'public-model.safetensors',
is_immutable: true
}),
createApiAsset({
name: 'another-my-model.safetensors',
is_immutable: false
})
]
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: [],
ownership: 'my-models'
})
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(filteredAssets.value.every((asset) => !asset.is_immutable)).toBe(
true
)
})
it('filters by ownership - public models only', async () => {
const assets = [
createApiAsset({ name: 'my-model.safetensors', is_immutable: false }),
createApiAsset({
name: 'public-model.safetensors',
is_immutable: true
}),
createApiAsset({
name: 'another-public-model.safetensors',
is_immutable: true
})
]
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: [],
ownership: 'public-models'
})
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(filteredAssets.value.every((asset) => asset.is_immutable)).toBe(
true
)
})
})
describe('Dynamic Category Extraction', () => {
it('extracts categories from asset tags', () => {
const assets = [

View File

@@ -1,440 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import type { components } from '@/types/comfyRegistryTypes'
type ReleaseNote = components['schemas']['ReleaseNote']
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: vi.fn((key) => key)
})),
createI18n: vi.fn(() => ({
global: {
locale: { value: 'en' }
}
}))
}))
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((content) => `<p>${content}</p>`)
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: vi.fn()
}))
describe('WhatsNewPopup', () => {
const mockReleaseStore = {
recentRelease: null as ReleaseNote | null,
shouldShowPopup: false,
handleWhatsNewSeen: vi.fn(),
releases: [] as ReleaseNote[],
fetchReleases: vi.fn()
}
const createWrapper = (props = {}) => {
return mount(WhatsNewPopup, {
props,
global: {
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.noReleaseNotes': 'No release notes available'
}
return translations[key] || key
})
}
}
})
}
beforeEach(async () => {
vi.clearAllMocks()
// Reset mock store
mockReleaseStore.recentRelease = null
mockReleaseStore.shouldShowPopup = false
mockReleaseStore.releases = []
// Mock release store
const { useReleaseStore } = await import(
'@/platform/updates/common/releaseStore'
)
vi.mocked(useReleaseStore).mockReturnValue(mockReleaseStore as any)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('visibility', () => {
it('should not show when shouldShowPopup is false', () => {
mockReleaseStore.shouldShowPopup = false
const wrapper = createWrapper()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
it('should show when shouldShowPopup is true and not dismissed', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'New features added',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
expect(wrapper.find('.whats-new-popup').exists()).toBe(true)
})
it('should hide when dismissed locally', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'New features added',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Initially visible
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
// Click close button
await wrapper.find('.close-button').trigger('click')
// Should be hidden
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
})
describe('content rendering', () => {
it('should render release content using renderMarkdownToHtml', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: '# Release Notes\n\nNew features',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Check that the content is rendered (renderMarkdownToHtml is mocked to return processed content)
expect(wrapper.find('.content-text').exists()).toBe(true)
const contentHtml = wrapper.find('.content-text').html()
expect(contentHtml).toContain('<p># Release Notes')
})
it('should handle missing release content', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: '',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.content-text').html()).toContain(
'whatsNewPopup.noReleaseNotes'
)
})
it('should handle markdown parsing errors gracefully', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content with\nnewlines',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Should show content even without markdown processing
expect(wrapper.find('.content-text').exists()).toBe(true)
})
})
describe('changelog URL generation', () => {
it('should generate English changelog URL with version anchor', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0-beta.1',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
// formatVersionAnchor replaces dots with dashes: 1.24.0-beta.1 -> v1-24-0-beta-1
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog#v1-24-0-beta-1'
)
})
it('should generate Chinese changelog URL when locale is zh', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper({
global: {
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.noReleaseNotes': 'No release notes available',
'whatsNewPopup.learnMore': 'Learn More'
}
return translations[key] || key
})
},
provide: {
// Mock vue-i18n locale as Chinese
locale: { value: 'zh' }
}
}
})
// Since the locale mocking doesn't work well in tests, just check the English URL for now
// In a real component test with proper i18n setup, this would show the Chinese URL
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog#v1-24-0'
)
})
it('should generate base changelog URL when no version available', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog'
)
})
})
describe('popup dismissal', () => {
it('should call handleWhatsNewSeen and emit event when closed', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
const wrapper = createWrapper()
// Click close button
await wrapper.find('.close-button').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
expect(wrapper.emitted('whats-new-dismissed')).toHaveLength(1)
})
it('should close when learn more link is clicked', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
const wrapper = createWrapper()
// Click learn more link
await wrapper.find('.learn-more-link').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
it('should handle cases where no release is available during close', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = null
const wrapper = createWrapper()
// Try to close
await wrapper.find('.close-button').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).not.toHaveBeenCalled()
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
})
describe('exposed methods', () => {
it('should expose show and hide methods', () => {
const wrapper = createWrapper()
expect(wrapper.vm.show).toBeDefined()
expect(wrapper.vm.hide).toBeDefined()
expect(typeof wrapper.vm.show).toBe('function')
expect(typeof wrapper.vm.hide).toBe('function')
})
it('should show popup when show method is called', async () => {
mockReleaseStore.shouldShowPopup = true
const wrapper = createWrapper()
// Initially hide it
wrapper.vm.hide()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
// Show it
wrapper.vm.show()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
})
it('should hide popup when hide method is called', async () => {
mockReleaseStore.shouldShowPopup = true
const wrapper = createWrapper()
// Initially visible
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
// Hide it
wrapper.vm.hide()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
})
describe('initialization', () => {
it('should fetch releases on mount if not already loaded', async () => {
mockReleaseStore.releases = []
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
createWrapper()
// Wait for onMounted
await nextTick()
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
})
it('should not fetch releases if already loaded', async () => {
mockReleaseStore.releases = [
{
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium' as const,
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
]
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
createWrapper()
// Wait for onMounted
await nextTick()
expect(mockReleaseStore.fetchReleases).not.toHaveBeenCalled()
})
})
describe('accessibility', () => {
it('should have proper aria-label for close button', () => {
const mockT = vi.fn((key) => (key === 'g.close' ? 'Close' : key))
vi.doMock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: mockT
}))
}))
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.close-button').attributes('aria-label')).toBe(
'Close'
)
})
it('should have proper link attributes for external changelog', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('target')).toBe('_blank')
expect(learnMoreLink.attributes('rel')).toBe('noopener,noreferrer')
})
})
})

View File

@@ -577,32 +577,32 @@ describe('useNodePricing', () => {
{
rendering_speed: 'Quality',
character_image: false,
expected: '$0.13/Run'
expected: '$0.09/Run'
},
{
rendering_speed: 'Quality',
character_image: true,
expected: '$0.29/Run'
expected: '$0.20/Run'
},
{
rendering_speed: 'Default',
character_image: false,
expected: '$0.09/Run'
expected: '$0.06/Run'
},
{
rendering_speed: 'Default',
character_image: true,
expected: '$0.21/Run'
expected: '$0.15/Run'
},
{
rendering_speed: 'Turbo',
character_image: false,
expected: '$0.04/Run'
expected: '$0.03/Run'
},
{
rendering_speed: 'Turbo',
character_image: true,
expected: '$0.14/Run'
expected: '$0.10/Run'
}
]
@@ -623,7 +623,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.04-0.11 x num_images/Run (varies with rendering speed & num_images)'
'$0.03-0.08 x num_images/Run (varies with rendering speed & num_images)'
)
})
@@ -635,7 +635,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.39/Run') // 0.09 * 3 * 1.43
expect(price).toBe('$0.27/Run') // 0.09 * 3
})
it('should multiply price by num_images for Turbo rendering speed', () => {
@@ -646,7 +646,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.21/Run') // 0.03 * 5 * 1.43
expect(price).toBe('$0.15/Run') // 0.03 * 5
})
})
@@ -770,7 +770,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$3.13/Run')
expect(price).toBe('$2.19/Run')
})
it('should return $6.37 for ray-2 4K 5s', () => {
@@ -782,7 +782,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$9.11/Run')
expect(price).toBe('$6.37/Run')
})
it('should return $0.35 for ray-1-6 model', () => {
@@ -794,7 +794,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.50/Run')
expect(price).toBe('$0.35/Run')
})
it('should return range when widgets are missing', () => {
@@ -803,7 +803,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.20-16.40/Run (varies with model, resolution & duration)'
'$0.14-11.47/Run (varies with model, resolution & duration)'
)
})
})
@@ -1192,7 +1192,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.26/Run') // 0.06 * 3 * 1.43
expect(price).toBe('$0.18/Run') // 0.06 * 3
})
it('should calculate dynamic pricing for IdeogramV2 based on num_images value', () => {
@@ -1202,7 +1202,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.46/Run') // 0.08 * 4 * 1.43
expect(price).toBe('$0.32/Run') // 0.08 * 4
})
it('should fall back to static display when num_images widget is missing for IdeogramV1', () => {
@@ -1210,7 +1210,7 @@ describe('useNodePricing', () => {
const node = createMockNode('IdeogramV1', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03-0.09 x num_images/Run')
expect(price).toBe('$0.02-0.06 x num_images/Run')
})
it('should fall back to static display when num_images widget is missing for IdeogramV2', () => {
@@ -1218,7 +1218,7 @@ describe('useNodePricing', () => {
const node = createMockNode('IdeogramV2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.07-0.11 x num_images/Run')
expect(price).toBe('$0.05-0.08 x num_images/Run')
})
it('should handle edge case when num_images value is 1 for IdeogramV1', () => {
@@ -1228,7 +1228,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.09/Run') // 0.06 * 1 * 1.43 (turbo=false by default)
expect(price).toBe('$0.06/Run') // 0.06 * 1 (turbo=false by default)
})
})
@@ -1435,7 +1435,7 @@ describe('useNodePricing', () => {
const node = createMockNode('RunwayTextToImageNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.11/Run')
expect(price).toBe('$0.08/Run')
})
it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => {
@@ -1445,7 +1445,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.71/Run') // 0.05 * 10 * 1.43
expect(price).toBe('$0.50/Run') // 0.05 * 10
})
it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => {
@@ -1453,7 +1453,7 @@ describe('useNodePricing', () => {
const node = createMockNode('RunwayImageToVideoNodeGen3a', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.0715/second')
expect(price).toBe('$0.05/second')
})
it('should handle zero duration for RunwayImageToVideoNodeGen3a', () => {
@@ -1473,7 +1473,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.36/Run') // Falls back to 5 seconds: 0.05 * 5 * 1.43
expect(price).toBe('$0.25/Run') // Falls back to 5 seconds: 0.05 * 5
})
})
@@ -1810,8 +1810,8 @@ describe('useNodePricing', () => {
// Test edge cases
const testCases = [
{ duration: 0, expected: '$0.00/Run' }, // Now correctly handles 0 duration
{ duration: 1, expected: '$0.07/Run' },
{ duration: 30, expected: '$2.15/Run' }
{ duration: 1, expected: '$0.05/Run' },
{ duration: 30, expected: '$1.50/Run' }
]
testCases.forEach(({ duration, expected }) => {
@@ -1828,7 +1828,7 @@ describe('useNodePricing', () => {
{ name: 'duration', value: 'invalid-string' }
])
// When Number('invalid-string') returns NaN, it falls back to 5 seconds
expect(getNodeDisplayPrice(node)).toBe('$0.36/Run')
expect(getNodeDisplayPrice(node)).toBe('$0.25/Run')
})
it('should handle missing duration widget gracefully', () => {
@@ -1841,7 +1841,7 @@ describe('useNodePricing', () => {
nodes.forEach((nodeType) => {
const node = createMockNode(nodeType, [])
expect(getNodeDisplayPrice(node)).toBe('$0.0715/second')
expect(getNodeDisplayPrice(node)).toBe('$0.05/second')
})
})
})

View File

@@ -1,7 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useExternalLink } from '@/composables/useExternalLink'
// Mock the environment utilities
vi.mock('@/utils/envUtil', () => ({
@@ -9,22 +6,27 @@ vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn()
}))
// Mock vue-i18n
const mockLocale = ref('en')
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: mockLocale
}))
// Provide a minimal i18n instance for the composable
const i18n = vi.hoisted(() => ({
global: {
locale: {
value: 'en'
}
}
}))
vi.mock('@/i18n', () => ({
i18n
}))
// Import after mocking to get the mocked versions
import { useExternalLink } from '@/composables/useExternalLink'
import { electronAPI, isElectron } from '@/utils/envUtil'
describe('useExternalLink', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to default state
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(false)
})
@@ -53,7 +55,7 @@ describe('useExternalLink', () => {
describe('buildDocsUrl', () => {
it('should build basic docs URL without locale', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog')
@@ -61,7 +63,7 @@ describe('useExternalLink', () => {
})
it('should build docs URL with Chinese (zh) locale when requested', () => {
mockLocale.value = 'zh'
i18n.global.locale.value = 'zh'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog', { includeLocale: true })
@@ -69,7 +71,7 @@ describe('useExternalLink', () => {
})
it('should build docs URL with Chinese (zh-TW) locale when requested', () => {
mockLocale.value = 'zh-TW'
i18n.global.locale.value = 'zh-TW'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog', { includeLocale: true })
@@ -77,7 +79,7 @@ describe('useExternalLink', () => {
})
it('should not include locale for English when requested', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog', { includeLocale: true })
@@ -92,7 +94,7 @@ describe('useExternalLink', () => {
})
it('should add platform suffix when requested', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(electronAPI).mockReturnValue({
getPlatform: () => 'darwin'
@@ -104,7 +106,7 @@ describe('useExternalLink', () => {
})
it('should add platform suffix with trailing slash', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(electronAPI).mockReturnValue({
getPlatform: () => 'win32'
@@ -116,7 +118,7 @@ describe('useExternalLink', () => {
})
it('should combine locale and platform', () => {
mockLocale.value = 'zh'
i18n.global.locale.value = 'zh'
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(electronAPI).mockReturnValue({
getPlatform: () => 'darwin'
@@ -133,7 +135,7 @@ describe('useExternalLink', () => {
})
it('should not add platform when not desktop', () => {
mockLocale.value = 'en'
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(false)
const { buildDocsUrl } = useExternalLink()

View File

@@ -0,0 +1,113 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { ref } from 'vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import StripePricingTable from '@/platform/cloud/subscription/components/StripePricingTable.vue'
const mockLoadStripeScript = vi.fn()
let currentConfig = {
publishableKey: 'pk_test_123',
pricingTableId: 'prctbl_123'
}
let hasConfig = true
vi.mock('@/config/stripePricingTableConfig', () => ({
getStripePricingTableConfig: () => currentConfig,
hasStripePricingTableConfig: () => hasConfig
}))
const mockIsLoaded = ref(false)
const mockIsLoading = ref(false)
const mockError = ref(null)
vi.mock(
'@/platform/cloud/subscription/composables/useStripePricingTableLoader',
() => ({
useStripePricingTableLoader: () => ({
loadScript: mockLoadStripeScript,
isLoaded: mockIsLoaded,
isLoading: mockIsLoading,
error: mockError
})
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const mountComponent = () =>
mount(StripePricingTable, {
global: {
plugins: [i18n]
}
})
describe('StripePricingTable', () => {
beforeEach(() => {
currentConfig = {
publishableKey: 'pk_test_123',
pricingTableId: 'prctbl_123'
}
hasConfig = true
mockLoadStripeScript.mockReset().mockResolvedValue(undefined)
mockIsLoaded.value = false
mockIsLoading.value = false
mockError.value = null
})
it('renders the Stripe pricing table when config is available', async () => {
const wrapper = mountComponent()
await flushPromises()
expect(mockLoadStripeScript).toHaveBeenCalled()
const stripePricingTable = wrapper.find('stripe-pricing-table')
expect(stripePricingTable.exists()).toBe(true)
expect(stripePricingTable.attributes('publishable-key')).toBe('pk_test_123')
expect(stripePricingTable.attributes('pricing-table-id')).toBe('prctbl_123')
})
it('shows missing config message when credentials are absent', () => {
hasConfig = false
currentConfig = { publishableKey: '', pricingTableId: '' }
const wrapper = mountComponent()
expect(
wrapper.find('[data-testid="stripe-table-missing-config"]').exists()
).toBe(true)
expect(mockLoadStripeScript).not.toHaveBeenCalled()
})
it('shows loading indicator when script is loading', async () => {
// Mock loadScript to never resolve, simulating loading state
mockLoadStripeScript.mockImplementation(() => new Promise(() => {}))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="stripe-table-loading"]').exists()).toBe(
true
)
expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
})
it('shows error indicator when script fails to load', async () => {
// Mock loadScript to reject, simulating error state
mockLoadStripeScript.mockRejectedValue(new Error('Script failed to load'))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="stripe-table-error"]').exists()).toBe(
true
)
expect(wrapper.find('stripe-pricing-table').exists()).toBe(false)
})
})

View File

@@ -1,35 +1,18 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue'
// Mock state refs that can be modified between tests
const mockIsActiveSubscription = ref(false)
const mockIsCancelled = ref(false)
const mockSubscriptionTier = ref<
'STANDARD' | 'CREATOR' | 'PRO' | 'FOUNDERS_EDITION' | null
>('CREATOR')
const TIER_TO_NAME: Record<string, string> = {
STANDARD: 'Standard',
CREATOR: 'Creator',
PRO: 'Pro',
FOUNDERS_EDITION: "Founder's Edition"
}
// Mock composables - using computed to match composable return types
// Mock composables
const mockSubscriptionData = {
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
isCancelled: computed(() => mockIsCancelled.value),
formattedRenewalDate: computed(() => '2024-12-31'),
formattedEndDate: computed(() => '2024-12-31'),
subscriptionTier: computed(() => mockSubscriptionTier.value),
subscriptionTierName: computed(() =>
mockSubscriptionTier.value ? TIER_TO_NAME[mockSubscriptionTier.value] : ''
),
isActiveSubscription: false,
isCancelled: false,
formattedRenewalDate: '2024-12-31',
formattedEndDate: '2024-12-31',
formattedMonthlyPrice: '$9.99',
manageSubscription: vi.fn(),
handleInvoiceHistory: vi.fn()
}
@@ -42,6 +25,7 @@ const mockCreditsData = {
const mockActionsData = {
isLoadingSupport: false,
refreshTooltip: 'Refreshes on 2024-12-31',
handleAddApiCredits: vi.fn(),
handleMessageSupport: vi.fn(),
handleRefresh: vi.fn(),
@@ -66,15 +50,6 @@ vi.mock(
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
() => ({
useSubscriptionDialog: () => ({
show: vi.fn()
})
})
)
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,
@@ -83,15 +58,12 @@ const i18n = createI18n({
en: {
subscription: {
title: 'Subscription',
titleUnsubscribed: 'Subscribe',
perMonth: '/ month',
subscribeNow: 'Subscribe Now',
manageSubscription: 'Manage Subscription',
partnerNodesBalance: 'Partner Nodes Balance',
partnerNodesDescription: 'Credits for partner nodes',
totalCredits: 'Total Credits',
creditsRemainingThisMonth: 'Credits remaining this month',
creditsYouveAdded: "Credits you've added",
monthlyBonusDescription: 'Monthly bonus',
prepaidDescription: 'Prepaid credits',
monthlyCreditsRollover: 'Monthly credits rollover info',
@@ -99,67 +71,11 @@ const i18n = createI18n({
viewUsageHistory: 'View Usage History',
addCredits: 'Add Credits',
yourPlanIncludes: 'Your plan includes',
viewMoreDetailsPlans: 'View more details about plans & pricing',
learnMore: 'Learn More',
messageSupport: 'Message Support',
invoiceHistory: 'Invoice History',
partnerNodesCredits: 'Partner nodes pricing',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}',
tiers: {
founder: {
name: "Founder's Edition",
price: '20.00',
benefits: {
monthlyCredits: '5,460',
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'
}
},
standard: {
name: 'Standard',
price: '20.00',
benefits: {
monthlyCredits: '4,200',
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'
}
},
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',
monthlyCreditsLabel: 'monthly credits',
maxDuration: '1 hr',
maxDurationLabel: 'max duration of each workflow run',
gpuLabel: 'RTX 6000 Pro (96GB VRAM)',
addCreditsLabel: 'Add more credits whenever',
customLoRAsLabel: 'Import your own LoRAs'
}
}
}
expiresDate: 'Expires {date}'
}
}
}
@@ -200,22 +116,18 @@ function createWrapper(overrides = {}) {
describe('SubscriptionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset mock state
mockIsActiveSubscription.value = false
mockIsCancelled.value = false
mockSubscriptionTier.value = 'CREATOR'
})
describe('subscription state functionality', () => {
it('shows correct UI for active subscription', () => {
mockIsActiveSubscription.value = true
mockSubscriptionData.isActiveSubscription = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Manage Subscription')
expect(wrapper.text()).toContain('Add Credits')
})
it('shows correct UI for inactive subscription', () => {
mockIsActiveSubscription.value = false
mockSubscriptionData.isActiveSubscription = false
const wrapper = createWrapper()
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
true
@@ -225,32 +137,18 @@ describe('SubscriptionPanel', () => {
})
it('shows renewal date for active non-cancelled subscription', () => {
mockIsActiveSubscription.value = true
mockIsCancelled.value = false
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = false
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Renews 2024-12-31')
})
it('shows expiry date for cancelled subscription', () => {
mockIsActiveSubscription.value = true
mockIsCancelled.value = true
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Expires 2024-12-31')
})
it('displays FOUNDERS_EDITION tier correctly', () => {
mockSubscriptionTier.value = 'FOUNDERS_EDITION'
const wrapper = createWrapper()
expect(wrapper.text()).toContain("Founder's Edition")
expect(wrapper.text()).toContain('5,460')
})
it('displays CREATOR tier correctly', () => {
mockSubscriptionTier.value = 'CREATOR'
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Creator')
expect(wrapper.text()).toContain('7,400')
})
})
describe('credit display functionality', () => {

View File

@@ -7,6 +7,23 @@ const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockT = vi.fn((key: string, values?: any) => {
if (key === 'subscription.nextBillingCycle') return 'next billing cycle'
if (key === 'subscription.refreshesOn') {
return `Refreshes to $${values?.monthlyCreditBonusUsd} on ${values?.date}`
}
return key
})
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: mockT
})
}
})
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
@@ -14,9 +31,12 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
})
}))
const mockFormattedRenewalDate = { value: '2024-12-31' }
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus
fetchStatus: mockFetchStatus,
formattedRenewalDate: mockFormattedRenewalDate
})
}))
@@ -42,6 +62,23 @@ Object.defineProperty(window, 'open', {
describe('useSubscriptionActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormattedRenewalDate.value = '2024-12-31'
})
describe('refreshTooltip', () => {
it('should format tooltip with renewal date', () => {
const { refreshTooltip } = useSubscriptionActions()
expect(refreshTooltip.value).toBe('Refreshes to $10 on 2024-12-31')
})
it('should use fallback text when no renewal date', () => {
mockFormattedRenewalDate.value = ''
const { refreshTooltip } = useSubscriptionActions()
expect(refreshTooltip.value).toBe(
'Refreshes to $10 on next billing cycle'
)
expect(mockT).toHaveBeenCalledWith('subscription.nextBillingCycle')
})
})
describe('handleAddApiCredits', () => {

View File

@@ -152,28 +152,10 @@ describe('useSubscription', () => {
expect(formattedRenewalDate.value).toBe('')
})
it('should return subscription tier from status', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'CREATOR',
renewal_date: '2025-11-16T12:00:00Z'
})
} as Response)
it('should format monthly price correctly', () => {
const { formattedMonthlyPrice } = useSubscription()
mockIsLoggedIn.value = true
const { subscriptionTier, fetchStatus } = useSubscription()
await fetchStatus()
expect(subscriptionTier.value).toBe('CREATOR')
})
it('should return null when subscription tier is not available', () => {
const { subscriptionTier } = useSubscription()
expect(subscriptionTier.value).toBeNull()
expect(formattedMonthlyPrice.value).toBe('$20')
})
})

View File

@@ -187,8 +187,7 @@ describe('useTemplateUrlLoader', () => {
'flux_simple',
'flux-kontext-dev',
'template123',
'My_Template-2',
'templates-1_click_multiple_scene_angles-v1.0' // template with version number containing dot
'My_Template-2'
]
for (const template of validTemplates) {

View File

@@ -208,11 +208,6 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Now should show second image
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
@@ -265,11 +260,6 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Alt text should update
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)