Compare commits

...

12 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
53 changed files with 888 additions and 330 deletions

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

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

@@ -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.35.0",
"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

@@ -7,7 +7,12 @@
<Skeleton width="8rem" height="2rem" />
</div>
<div v-else class="flex items-center gap-1">
<Tag severity="secondary" rounded class="p-1 text-amber-400">
<Tag
v-if="!showCreditsOnly"
severity="secondary"
rounded
class="p-1 text-amber-400"
>
<template #icon>
<i
:class="
@@ -18,7 +23,9 @@
/>
</template>
</Tag>
<div :class="textClass">{{ formattedBalance }}</div>
<div :class="textClass">
{{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }}
</div>
</div>
</template>
@@ -32,8 +39,9 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const { textClass } = defineProps<{
const { textClass, showCreditsOnly } = defineProps<{
textClass?: string
showCreditsOnly?: boolean
}>()
const authStore = useFirebaseAuthStore()
@@ -50,4 +58,14 @@ const formattedBalance = computed(() => {
})
return `${amount} ${t('credits.credits')}`
})
const formattedCreditsOnly = computed(() => {
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
})
return amount
})
</script>

View File

@@ -1,35 +1,43 @@
<template>
<!-- New Credits Design (default) -->
<div
v-if="useNewDesign"
class="flex w-96 flex-col gap-8 p-8 bg-node-component-surface rounded-2xl border border-border-primary"
>
<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-foreground-primary m-0">
{{ $t('credits.topUp.addMoreCredits') }}
<h1 class="text-2xl font-semibold text-white m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h1>
<p class="text-sm text-foreground-secondary m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0 w-96">
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
</div>
<div v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
</div>
<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" />
<span class="text-sm text-foreground-secondary">{{
<UserCredit text-class="text-3xl font-bold" show-credits-only />
<span class="text-sm text-muted-foreground">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="refreshDate" class="text-sm text-foreground-secondary">
{{ $t('credits.refreshes', { date: refreshDate }) }}
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
</div>
</div>
<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-foreground-secondary">
<span class="text-sm text-muted-foreground">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
@@ -42,7 +50,7 @@
@select="selectedCredits = option.credits"
/>
</div>
<div class="text-xs text-foreground-secondary">
<div class="text-xs text-muted-foreground w-96">
{{ $t('credits.topUp.templateNote') }}
</div>
</div>
@@ -53,7 +61,8 @@
:loading="loading"
severity="primary"
:label="$t('credits.topUp.buy')"
class="w-full"
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
:pt="{ label: { class: 'text-white' } }"
@click="handleBuy"
/>
</div>
@@ -121,6 +130,7 @@ import {
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
@@ -132,18 +142,17 @@ interface CreditOption {
}
const {
refreshDate,
isInsufficientCredits = false,
amountOptions = [5, 10, 20, 50],
preselectedAmountOption = 10
} = defineProps<{
refreshDate?: string
isInsufficientCredits?: boolean
amountOptions?: number[]
preselectedAmountOption?: number
}>()
const { flags } = useFeatureFlags()
const { formattedRenewalDate } = useSubscription()
// Use feature flag to determine design - defaults to true (new design)
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
@@ -157,20 +166,20 @@ const loading = ref(false)
const creditOptions: CreditOption[] = [
{
credits: 1000,
description: t('credits.topUp.videosEstimate', { count: 100 })
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 41 })
},
{
credits: 5000,
description: t('credits.topUp.videosEstimate', { count: 500 })
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 82 })
},
{
credits: 10000,
description: t('credits.topUp.videosEstimate', { count: 1000 })
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 184 })
},
{
credits: 20000,
description: t('credits.topUp.videosEstimate', { count: 2000 })
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 412 })
}
]

View File

@@ -3,19 +3,17 @@
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
:class="[
selected
? 'bg-surface-secondary border-2 border-primary'
: 'bg-surface-tertiary border border-border-primary hover:bg-surface-secondary'
? 'bg-secondary-background border-2 border-border-default'
: 'bg-component-node-disabled hover:bg-secondary-background border-2 border-transparent'
]"
@click="$emit('select')"
>
<div class="flex flex-col">
<span class="text-base font-medium text-foreground-primary">
{{ formattedCredits }}
</span>
<span class="text-sm text-foreground-secondary">
{{ description }}
</span>
</div>
<span class="text-base font-bold text-white">
{{ formattedCredits }}
</span>
<span class="text-sm font-normal text-white">
{{ description }}
</span>
</div>
</template>
@@ -38,6 +36,10 @@ defineEmits<{
const { locale } = useI18n()
const formattedCredits = computed(() => {
return formatCredits({ value: credits, locale: locale.value })
return formatCredits({
value: credits,
locale: locale.value,
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
})
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -203,7 +203,7 @@
"relativeTime": {
"now": "now",
"yearsAgo": "{count}y ago",
"monthsAgo": "{count}mo ago",
"monthsAgo": "{count}mo ago",
"weeksAgo": "{count}w ago",
"daysAgo": "{count}d ago",
"hoursAgo": "{count}h ago",
@@ -475,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": {
@@ -1053,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",
@@ -1847,6 +1860,8 @@
"seeDetails": "See details",
"topUp": "Top Up",
"addMoreCredits": "Add more credits",
"addMoreCreditsToRun": "Add more credits to run",
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
"creditsDescription": "Credits are used to run workflows or partner nodes.",
"howManyCredits": "How many credits would you like to add?",
"videosEstimate": "~{count} videos*",
@@ -1869,7 +1884,7 @@
"accountInitialized": "Account initialized",
"unified": {
"message": "Credits have been unified",
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits.\nLearn more here."
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
}
},
"subscription": {
@@ -1878,7 +1893,7 @@
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "USD / month",
"perMonth": "/ month",
"renewsDate": "Renews {date}",
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
"expiresDate": "Expires {date}",
@@ -1893,6 +1908,10 @@
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsRemainingThisMonth": "Credits remaining for this month",
"creditsYouveAdded": "Credits you've added",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
@@ -1903,6 +1922,55 @@
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"tiers": {
"founder": {
"name": "Founder's Edition Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "5,460 monthly credits",
"maxDuration": "30 min max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever"
}
},
"standard": {
"name": "Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "4,200 monthly credits",
"maxDuration": "30 min max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever",
"customLoRAs": "Import your own LoRAs",
"videoEstimate": "164"
}
},
"creator": {
"name": "Creator",
"price": "35.00",
"benefits": {
"monthlyCredits": "7,400",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs"
}
},
"pro": {
"name": "Pro",
"price": "100.00",
"benefits": {
"monthlyCredits": "21,100 monthly credits",
"maxDuration": "1 hr max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever",
"customLoRAs": "Import your own LoRAs",
"videoEstimate": "821"
}
}
},
"required": {
"title": "Subscribe to",
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
@@ -1922,10 +1990,10 @@
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"partnerNodesCredits": "Partner Nodes pricing table"
"partnerNodesCredits": "Partner nodes pricing"
},
"userSettings": {
"title": "User Settings",
"title": "My Account Settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",
@@ -2324,4 +2392,4 @@
"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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,16 +9,20 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
*/
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
const { t, locale } = useI18n()
const { locale } = useI18n()
const formatBalance = (maybeCents?: number) => {
// Backend returns cents despite the *_micros naming convention.
const cents = maybeCents ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
})
return `${amount} ${t('credits.credits')}`
return amount
}
const totalCredits = computed(() =>

View File

@@ -85,7 +85,7 @@ export function useSettingUI(
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
)
}

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

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

View File

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

View File

@@ -32,16 +32,12 @@ describe('CreditTopUpOption', () => {
expect(wrapper.text()).toContain('~500 videos*')
})
it('applies selected styling when selected', () => {
const wrapper = mountOption({ selected: true })
expect(wrapper.find('div').classes()).toContain('bg-surface-secondary')
expect(wrapper.find('div').classes()).toContain('border-primary')
})
it('applies unselected styling when not selected', () => {
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain('bg-surface-tertiary')
expect(wrapper.find('div').classes()).toContain('border-border-primary')
expect(wrapper.find('div').classes()).toContain(
'bg-component-node-disabled'
)
expect(wrapper.find('div').classes()).toContain('border-transparent')
})
it('emits select event when clicked', async () => {

View File

@@ -84,22 +84,22 @@ describe('useSubscriptionCredits', () => {
})
describe('totalCredits', () => {
it('should return "0.00 Credits" when balance is null', () => {
it('should return "0" when balance is null', () => {
authStore.balance = null
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00 Credits')
expect(totalCredits.value).toBe('0')
})
it('should return "0.00 Credits" when amount_micros is missing', () => {
it('should return "0" when amount_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00 Credits')
expect(totalCredits.value).toBe('0')
})
it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('211.00 Credits')
expect(totalCredits.value).toBe('211')
})
it('should handle formatting errors by throwing', async () => {
@@ -116,10 +116,10 @@ describe('useSubscriptionCredits', () => {
})
describe('monthlyBonusCredits', () => {
it('should return "0.00 Credits" when cloud_credit_balance_micros is missing', () => {
it('should return "0" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0.00 Credits')
expect(monthlyBonusCredits.value).toBe('0')
})
it('should format cloud_credit_balance_micros correctly', () => {
@@ -127,15 +127,15 @@ describe('useSubscriptionCredits', () => {
cloud_credit_balance_micros: 200
} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('422.00 Credits')
expect(monthlyBonusCredits.value).toBe('422')
})
})
describe('prepaidCredits', () => {
it('should return "0.00 Credits" when prepaid_balance_micros is missing', () => {
it('should return "0" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0.00 Credits')
expect(prepaidCredits.value).toBe('0')
})
it('should format prepaid_balance_micros correctly', () => {
@@ -143,7 +143,7 @@ describe('useSubscriptionCredits', () => {
prepaid_balance_micros: 300
} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('633.00 Credits')
expect(prepaidCredits.value).toBe('633')
})
})

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)

View File

@@ -6,7 +6,6 @@
"lib": [
"ES2023",
"ES2023.Array",
"ESNext.Iterator",
"DOM",
"DOM.Iterable"
],