mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 22:58:08 +00:00
Compare commits
1 Commits
fix/top-up
...
fix/vue-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad820ef2af |
@@ -1,13 +1,13 @@
|
||||
// Import only English locale eagerly as the default/fallback
|
||||
// ESLint cannot statically resolve dynamic imports with path aliases (@frontend-locales/*),
|
||||
// but these are properly configured in tsconfig.json and resolved by Vite at build time.
|
||||
|
||||
// eslint-disable-next-line import-x/no-unresolved
|
||||
import enCommands from '@frontend-locales/en/commands.json' with { type: 'json' }
|
||||
|
||||
// eslint-disable-next-line import-x/no-unresolved
|
||||
import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
|
||||
// eslint-disable-next-line import-x/no-unresolved
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
|
||||
// eslint-disable-next-line import-x/no-unresolved
|
||||
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -27,7 +27,7 @@ function buildLocale<
|
||||
|
||||
// Locale loader map - dynamically import locales only when needed
|
||||
// ESLint cannot statically resolve these dynamic imports, but they are valid at build time
|
||||
|
||||
/* eslint-disable import-x/no-unresolved */
|
||||
const localeLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 95 KiB |
@@ -24,12 +24,7 @@
|
||||
>
|
||||
<SplitterPanel
|
||||
v-if="sidebarLocation === 'left' && !focusMode"
|
||||
:class="
|
||||
cn(
|
||||
'side-bar-panel bg-comfy-menu-bg pointer-events-auto',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
"
|
||||
class="side-bar-panel bg-comfy-menu-bg pointer-events-auto"
|
||||
:min-size="10"
|
||||
:size="20"
|
||||
:style="{
|
||||
@@ -75,12 +70,7 @@
|
||||
|
||||
<SplitterPanel
|
||||
v-if="sidebarLocation === 'right' && !focusMode"
|
||||
:class="
|
||||
cn(
|
||||
'side-bar-panel pointer-events-auto',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
"
|
||||
class="side-bar-panel pointer-events-auto"
|
||||
:min-size="10"
|
||||
:size="20"
|
||||
:style="{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<component
|
||||
:is="currentButton"
|
||||
:key="isSubscriptionRequirementMet ? 'queue' : 'subscribe'"
|
||||
:key="isActiveSubscription ? 'queue' : 'subscribe'"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
const { isSubscriptionRequirementMet } = useSubscription()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
isSubscriptionRequirementMet.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
v-else-if="isSubscriptionRequirementMet"
|
||||
v-else-if="isActiveSubscription"
|
||||
:label="$t('credits.purchaseCredits')"
|
||||
:loading="loading"
|
||||
@click="handlePurchaseCreditsClick"
|
||||
@@ -125,7 +125,6 @@ import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.v
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -145,11 +144,7 @@ const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const subscription = isCloud ? useSubscription() : null
|
||||
const isSubscriptionRequirementMet = computed(() => {
|
||||
if (!isCloud) return true
|
||||
return subscription?.isSubscriptionRequirementMet.value ?? false
|
||||
})
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
|
||||
@@ -257,11 +257,9 @@ useResizeObserver(footerRef, (entries) => {
|
||||
})
|
||||
|
||||
// Determine if we should show compact mode (icon only)
|
||||
// Threshold matches when grid switches from 2 columns to 1 column
|
||||
// 2 columns need about ~430px
|
||||
const COMPACT_MODE_THRESHOLD_PX = 430
|
||||
// Threshold: 350px or less shows icon only
|
||||
const isCompact = computed(
|
||||
() => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
|
||||
() => footerWidth.value > 0 && footerWidth.value <= 350
|
||||
)
|
||||
|
||||
// Hover state for selection count button
|
||||
|
||||
@@ -82,7 +82,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
const mockFetchStatus = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isSubscriptionRequirementMet: { value: true },
|
||||
isActiveSubscription: { value: true },
|
||||
fetchStatus: mockFetchStatus
|
||||
}))
|
||||
}))
|
||||
@@ -121,11 +121,6 @@ describe('CurrentUserPopover', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const findButtonByLabel = (wrapper: VueWrapper, label: string) =>
|
||||
wrapper
|
||||
.findAllComponents(Button)
|
||||
.find((button) => button.props('label') === label)!
|
||||
|
||||
const mountComponent = (): VueWrapper => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -166,7 +161,8 @@ describe('CurrentUserPopover', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the settings button (third button)
|
||||
const settingsButton = findButtonByLabel(wrapper, 'User Settings')
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const settingsButton = buttons[2]
|
||||
|
||||
// Click the settings button
|
||||
await settingsButton.trigger('click')
|
||||
@@ -183,7 +179,8 @@ describe('CurrentUserPopover', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the logout button (last button)
|
||||
const logoutButton = findButtonByLabel(wrapper, 'Log Out')
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const logoutButton = buttons[4]
|
||||
|
||||
// Click the logout button
|
||||
await logoutButton.trigger('click')
|
||||
@@ -200,10 +197,8 @@ describe('CurrentUserPopover', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the Partner Nodes info button (first one)
|
||||
const partnerNodesButton = findButtonByLabel(
|
||||
wrapper,
|
||||
'Partner Nodes pricing table'
|
||||
)
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const partnerNodesButton = buttons[0]
|
||||
|
||||
// Click the Partner Nodes button
|
||||
await partnerNodesButton.trigger('click')
|
||||
@@ -223,7 +218,8 @@ describe('CurrentUserPopover', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
// Find all buttons and get the top-up button (second one)
|
||||
const topUpButton = findButtonByLabel(wrapper, 'Top Up')
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const topUpButton = buttons[1]
|
||||
|
||||
// Click the top-up button
|
||||
await topUpButton.trigger('click')
|
||||
|
||||
@@ -23,49 +23,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="SubscriptionSection"
|
||||
v-if="SubscriptionSection"
|
||||
@top-up="handleTopUp"
|
||||
@open-partner-info="handleOpenPartnerNodesInfo"
|
||||
@open-plan-settings="handleOpenPlanAndCreditsSettings"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<UserCredit text-class="text-2xl" />
|
||||
<Button
|
||||
:label="$t('subscription.partnerNodesCredits')"
|
||||
severity="secondary"
|
||||
text
|
||||
size="small"
|
||||
class="pl-6 p-0 h-auto justify-start"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'hover:bg-transparent active:bg-transparent'
|
||||
}
|
||||
}"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isActiveSubscription" class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<UserCredit text-class="text-2xl" />
|
||||
<Button
|
||||
:label="$t('credits.topUp.topUp')"
|
||||
:label="$t('subscription.partnerNodesCredits')"
|
||||
severity="secondary"
|
||||
text
|
||||
size="small"
|
||||
@click="handleTopUp"
|
||||
class="pl-6 p-0 h-auto justify-start"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'hover:bg-transparent active:bg-transparent'
|
||||
}
|
||||
}"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t(planSettingsLabel)"
|
||||
icon="pi pi-receipt"
|
||||
text
|
||||
fluid
|
||||
:label="$t('credits.topUp.topUp')"
|
||||
severity="secondary"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
size="small"
|
||||
@click="handleTopUp"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<SubscribeButton
|
||||
v-else
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="small"
|
||||
variant="gradient"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
@@ -79,6 +67,17 @@
|
||||
@click="handleOpenUserSettings"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isActiveSubscription"
|
||||
class="justify-start"
|
||||
:label="$t(planSettingsLabel)"
|
||||
icon="pi pi-receipt"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
/>
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<Button
|
||||
@@ -96,13 +95,15 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import { defineAsyncComponent, onMounted } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -113,18 +114,15 @@ const emit = defineEmits<{
|
||||
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
const planSettingsLabel = 'settingsCategories.Credits'
|
||||
|
||||
const SubscriptionSection = isCloud
|
||||
? defineAsyncComponent(
|
||||
() => import('./CurrentUserPopoverSubscriptionSection.vue')
|
||||
)
|
||||
: null
|
||||
const planSettingsLabel = isCloud
|
||||
? 'settingsCategories.PlanCredits'
|
||||
: 'settingsCategories.Credits'
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
@@ -163,6 +161,10 @@ const handleLogout = async () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSubscribed = async () => {
|
||||
await fetchStatus()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<div v-if="isSubscriptionRequirementMet" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<UserCredit text-class="text-2xl" />
|
||||
<Button
|
||||
:label="$t('subscription.partnerNodesCredits')"
|
||||
severity="secondary"
|
||||
text
|
||||
size="small"
|
||||
class="pl-6 p-0 h-auto justify-start"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'hover:bg-transparent active:bg-transparent'
|
||||
}
|
||||
}"
|
||||
@click="handleOpenPartnerInfo"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('credits.topUp.topUp')"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleTopUp"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="justify-start"
|
||||
:label="$t('settingsCategories.PlanCredits')"
|
||||
icon="pi pi-receipt"
|
||||
text
|
||||
fluid
|
||||
severity="secondary"
|
||||
@click="handleOpenPlanSettings"
|
||||
/>
|
||||
</div>
|
||||
<SubscribeButton
|
||||
v-else
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="small"
|
||||
variant="gradient"
|
||||
class="w-full"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'top-up': []
|
||||
'open-partner-info': []
|
||||
'open-plan-settings': []
|
||||
}>()
|
||||
|
||||
const { isSubscriptionRequirementMet, fetchStatus } = useSubscription()
|
||||
|
||||
const handleSubscribed = async () => {
|
||||
await fetchStatus()
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
emit('top-up')
|
||||
}
|
||||
|
||||
const handleOpenPartnerInfo = () => {
|
||||
emit('open-partner-info')
|
||||
}
|
||||
|
||||
const handleOpenPlanSettings = () => {
|
||||
emit('open-plan-settings')
|
||||
}
|
||||
</script>
|
||||
@@ -6,6 +6,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -81,13 +82,8 @@ export const useFirebaseAuthActions = () => {
|
||||
)
|
||||
|
||||
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
|
||||
if (isCloud) {
|
||||
const { useSubscription } = await import(
|
||||
'@/platform/cloud/subscription/composables/useSubscription'
|
||||
)
|
||||
const { isSubscriptionRequirementMet } = useSubscription()
|
||||
if (!isSubscriptionRequirementMet.value) return
|
||||
}
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
if (!isActiveSubscription.value) return
|
||||
|
||||
const response = await authStore.initiateCreditPurchase({
|
||||
amount_micros: usdToMicros(amount),
|
||||
|
||||
@@ -1768,9 +1768,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
ByteDanceSeedreamNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const sequentialGenerationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'sequential_image_generation'
|
||||
) as IComboWidget
|
||||
@@ -1778,31 +1775,21 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
(w) => w.name === 'max_images'
|
||||
) as IComboWidget
|
||||
|
||||
const model = String(modelWidget?.value ?? '').toLowerCase()
|
||||
let pricePerImage = 0.03 // default for seedream-4-0-250828 and fallback
|
||||
if (model.includes('seedream-4-5-251128')) {
|
||||
pricePerImage = 0.04
|
||||
} else if (model.includes('seedream-4-0-250828')) {
|
||||
pricePerImage = 0.03
|
||||
if (!sequentialGenerationWidget || !maxImagesWidget)
|
||||
return '$0.03/Run ($0.03 for one output image)'
|
||||
|
||||
if (
|
||||
String(sequentialGenerationWidget.value).toLowerCase() === 'disabled'
|
||||
) {
|
||||
return '$0.03/Run'
|
||||
}
|
||||
|
||||
if (!sequentialGenerationWidget || !maxImagesWidget) {
|
||||
return `$${pricePerImage}/Run ($${pricePerImage} for one output image)`
|
||||
}
|
||||
|
||||
const seqMode = String(sequentialGenerationWidget.value).toLowerCase()
|
||||
if (seqMode === 'disabled') {
|
||||
return `$${pricePerImage}/Run`
|
||||
}
|
||||
|
||||
const maxImagesRaw = Number(maxImagesWidget.value)
|
||||
const maxImages =
|
||||
Number.isFinite(maxImagesRaw) && maxImagesRaw > 0 ? maxImagesRaw : 1
|
||||
const maxImages = Number(maxImagesWidget.value)
|
||||
if (maxImages === 1) {
|
||||
return `$${pricePerImage}/Run`
|
||||
return '$0.03/Run'
|
||||
}
|
||||
const totalCost = (pricePerImage * maxImages).toFixed(2)
|
||||
return `$${totalCost}/Run ($${pricePerImage} for one output image)`
|
||||
const cost = (0.03 * maxImages).toFixed(2)
|
||||
return `$${cost}/Run ($0.03 for one output image)`
|
||||
}
|
||||
},
|
||||
ByteDanceTextToVideoNode: {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
@@ -48,7 +46,6 @@ import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
@@ -66,8 +63,7 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
||||
|
||||
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
|
||||
|
||||
const defaultSubscriptionState = computed(() => true)
|
||||
const noop = () => {}
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
@@ -89,11 +85,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
const subscription = isCloud ? useSubscription() : null
|
||||
const subscriptionState =
|
||||
subscription?.isSubscriptionRequirementMet ?? defaultSubscriptionState
|
||||
const subscriptionDialog = subscription?.showSubscriptionDialog ?? noop
|
||||
|
||||
const moveSelectedNodes = (
|
||||
positionUpdater: (pos: Point, gridSize: number) => Point
|
||||
) => {
|
||||
@@ -484,8 +475,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!subscriptionState.value) {
|
||||
subscriptionDialog()
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -507,8 +498,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!subscriptionState.value) {
|
||||
subscriptionDialog()
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -529,8 +520,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}) => {
|
||||
useTelemetry()?.trackRunButton(metadata)
|
||||
if (!subscriptionState.value) {
|
||||
subscriptionDialog()
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { without } from 'es-toolkit'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type {
|
||||
@@ -8,7 +10,6 @@ import type {
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { isStrings } from '@/utils/typeGuardUtil'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
@@ -242,6 +244,30 @@ function changeOutputType(
|
||||
}
|
||||
}
|
||||
|
||||
function combineTypes(...types: ISlotType[]): ISlotType | undefined {
|
||||
if (!isStrings(types)) return undefined
|
||||
|
||||
const withoutWildcards = without(types, '*')
|
||||
if (withoutWildcards.length === 0) return '*'
|
||||
|
||||
const typeLists: string[][] = withoutWildcards.map((type) => type.split(','))
|
||||
|
||||
const combinedTypes = intersection(...typeLists)
|
||||
if (combinedTypes.length === 0) return undefined
|
||||
|
||||
return combinedTypes.join(',')
|
||||
}
|
||||
|
||||
function intersection(...sets: string[][]): string[] {
|
||||
const itemCounts: Record<string, number> = {}
|
||||
for (const set of sets)
|
||||
for (const item of new Set(set))
|
||||
itemCounts[item] = (itemCounts[item] ?? 0) + 1
|
||||
return Object.entries(itemCounts)
|
||||
.filter(([, count]) => count == sets.length)
|
||||
.map(([key]) => key)
|
||||
}
|
||||
|
||||
function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
if (node.comfyMatchType) return
|
||||
node.comfyMatchType = {}
|
||||
@@ -264,6 +290,8 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
if (!matchGroup) return
|
||||
if (iscon && linf) {
|
||||
const { output, subgraphInput } = linf.resolve(this.graph)
|
||||
//TODO: fix this bug globally. A link type (and therefore color)
|
||||
//should be the combinedType of origin and target type
|
||||
const connectingType = (output ?? subgraphInput)?.type
|
||||
if (connectingType) linf.type = connectingType
|
||||
}
|
||||
@@ -288,14 +316,14 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
...connectedTypes.slice(0, idx),
|
||||
...connectedTypes.slice(idx + 1)
|
||||
]
|
||||
const combinedType = commonType(
|
||||
const combinedType = combineTypes(
|
||||
...otherConnected,
|
||||
matchGroup[input.name]
|
||||
)
|
||||
if (!combinedType) throw new Error('invalid connection')
|
||||
input.type = combinedType
|
||||
})
|
||||
const outputType = commonType(...connectedTypes)
|
||||
const outputType = combineTypes(...connectedTypes)
|
||||
if (!outputType) throw new Error('invalid connection')
|
||||
this.outputs.forEach((output, idx) => {
|
||||
if (!(outputGroups?.[idx] == matchKey)) return
|
||||
|
||||
@@ -15,10 +15,10 @@ useExtensionService().registerExtension({
|
||||
|
||||
setup: async () => {
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
const { isSubscriptionRequirementMet } = useSubscription()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
|
||||
watchDebounced(
|
||||
[isLoggedIn, isSubscriptionRequirementMet],
|
||||
[isLoggedIn, isActiveSubscription],
|
||||
() => {
|
||||
if (!isLoggedIn.value) return
|
||||
void refreshRemoteConfig()
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import { commonType, toClass } from '@/lib/litegraph/src/utils/type'
|
||||
|
||||
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import type { DragAndScale } from './DragAndScale'
|
||||
@@ -85,6 +84,7 @@ import { findFreeSlotOfType } from './utils/collections'
|
||||
import { warnDeprecated } from './utils/feedback'
|
||||
import { distributeSpace } from './utils/spaceDistribution'
|
||||
import { truncateText } from './utils/textUtils'
|
||||
import { toClass } from './utils/type'
|
||||
import { BaseWidget } from './widgets/BaseWidget'
|
||||
import { toConcreteWidget } from './widgets/widgetMap'
|
||||
import type { WidgetTypeMap } from './widgets/widgetMap'
|
||||
@@ -2832,12 +2832,9 @@ export class LGraphNode
|
||||
inputNode.disconnectInput(inputIndex, true)
|
||||
}
|
||||
|
||||
const maybeCommonType =
|
||||
input.type && output.type && commonType(input.type, output.type)
|
||||
|
||||
const link = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
maybeCommonType || input.type || output.type,
|
||||
input.type || output.type,
|
||||
this.id,
|
||||
outputIndex,
|
||||
inputNode.id,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { without } from 'es-toolkit'
|
||||
|
||||
import type { IColorable, ISlotType } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IColorable } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
/**
|
||||
* Converts a plain object to a class instance if it is not already an instance of the class.
|
||||
@@ -28,31 +26,3 @@ export function isColorable(obj: unknown): obj is IColorable {
|
||||
'getColorOption' in obj
|
||||
)
|
||||
}
|
||||
|
||||
export function commonType(...types: ISlotType[]): ISlotType | undefined {
|
||||
if (!isStrings(types)) return undefined
|
||||
|
||||
const withoutWildcards = without(types, '*')
|
||||
if (withoutWildcards.length === 0) return '*'
|
||||
|
||||
const typeLists: string[][] = withoutWildcards.map((type) => type.split(','))
|
||||
|
||||
const combinedTypes = intersection(...typeLists)
|
||||
if (combinedTypes.length === 0) return undefined
|
||||
|
||||
return combinedTypes.join(',')
|
||||
}
|
||||
|
||||
function intersection(...sets: string[][]): string[] {
|
||||
const itemCounts: Record<string, number> = {}
|
||||
for (const set of sets)
|
||||
for (const item of new Set(set))
|
||||
itemCounts[item] = (itemCounts[item] ?? 0) + 1
|
||||
return Object.entries(itemCounts)
|
||||
.filter(([, count]) => count === sets.length)
|
||||
.map(([key]) => key)
|
||||
}
|
||||
|
||||
function isStrings(types: unknown[]): types is string[] {
|
||||
return types.every((t) => typeof t === 'string')
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function useAssetBrowser(
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('all')
|
||||
const filters = ref<FilterState>({
|
||||
sortBy: 'recent',
|
||||
sortBy: 'name-asc',
|
||||
fileFormats: [],
|
||||
baseModels: []
|
||||
})
|
||||
|
||||
@@ -51,8 +51,7 @@ const emit = defineEmits<{
|
||||
subscribed: []
|
||||
}>()
|
||||
|
||||
const { subscribe, isSubscriptionRequirementMet, fetchStatus } =
|
||||
useSubscription()
|
||||
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isLoading = ref(false)
|
||||
@@ -77,7 +76,7 @@ const startPollingSubscriptionStatus = () => {
|
||||
|
||||
await fetchStatus()
|
||||
|
||||
if (isSubscriptionRequirementMet.value) {
|
||||
if (isActiveSubscription.value) {
|
||||
stopPolling()
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
emit('subscribed')
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-inter font-semibold leading-tight">
|
||||
{{
|
||||
isSubscriptionRequirementMet
|
||||
isActiveSubscription
|
||||
? $t('subscription.title')
|
||||
: $t('subscription.titleUnsubscribed')
|
||||
}}
|
||||
@@ -27,7 +27,7 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isSubscriptionRequirementMet"
|
||||
v-if="isActiveSubscription"
|
||||
class="text-sm text-text-secondary"
|
||||
>
|
||||
<template v-if="isCancelled">
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isSubscriptionRequirementMet"
|
||||
v-if="isActiveSubscription"
|
||||
:label="$t('subscription.manageSubscription')"
|
||||
severity="secondary"
|
||||
class="text-xs bg-interface-menu-component-surface-selected"
|
||||
@@ -196,7 +196,7 @@
|
||||
{{ $t('subscription.viewUsageHistory') }}
|
||||
</a>
|
||||
<Button
|
||||
v-if="isSubscriptionRequirementMet"
|
||||
v-if="isActiveSubscription"
|
||||
:label="$t('subscription.addCredits')"
|
||||
severity="secondary"
|
||||
class="p-2 min-h-8 bg-interface-menu-component-surface-selected"
|
||||
@@ -320,7 +320,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
|
||||
const {
|
||||
isSubscriptionRequirementMet,
|
||||
isActiveSubscription,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
|
||||
@@ -31,12 +31,8 @@ function useSubscriptionInternal() {
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isSubscriptionCheckRequired = computed(() =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
)
|
||||
|
||||
const isSubscriptionRequirementMet = computed(() => {
|
||||
if (!isSubscriptionCheckRequired.value) return true
|
||||
const isSubscribedOrIsNotCloud = computed(() => {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
|
||||
|
||||
return subscriptionStatus.value?.is_active ?? false
|
||||
})
|
||||
@@ -110,12 +106,12 @@ function useSubscriptionInternal() {
|
||||
}
|
||||
|
||||
const shouldWatchCancellation = (): boolean =>
|
||||
isSubscriptionCheckRequired.value
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
|
||||
const { startCancellationWatcher, stopCancellationWatcher } =
|
||||
useSubscriptionCancellationWatcher({
|
||||
fetchStatus,
|
||||
isSubscriptionRequirementMet,
|
||||
isActiveSubscription: isSubscribedOrIsNotCloud,
|
||||
subscriptionStatus,
|
||||
telemetry,
|
||||
shouldWatchCancellation
|
||||
@@ -127,11 +123,9 @@ function useSubscriptionInternal() {
|
||||
}
|
||||
|
||||
const requireActiveSubscription = async (): Promise<void> => {
|
||||
if (!isSubscriptionCheckRequired.value) return
|
||||
|
||||
await fetchSubscriptionStatus()
|
||||
|
||||
if (!isSubscriptionRequirementMet.value) {
|
||||
if (!isSubscribedOrIsNotCloud.value) {
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
@@ -229,7 +223,7 @@ function useSubscriptionInternal() {
|
||||
|
||||
return {
|
||||
// State
|
||||
isSubscriptionRequirementMet,
|
||||
isActiveSubscription: isSubscribedOrIsNotCloud,
|
||||
isCancelled,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
|
||||
@@ -36,8 +36,8 @@ export function useSubscriptionActions() {
|
||||
void handleRefresh()
|
||||
})
|
||||
|
||||
const handleAddApiCredits = async () => {
|
||||
await dialogService.showTopUpCreditsDialog()
|
||||
const handleAddApiCredits = () => {
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ const CANCELLATION_BACKOFF_MULTIPLIER = 3 // 5s, 15s, 45s, 135s intervals
|
||||
|
||||
type CancellationWatcherOptions = {
|
||||
fetchStatus: () => Promise<CloudSubscriptionStatusResponse | null | void>
|
||||
isSubscriptionRequirementMet: ComputedRef<boolean>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
subscriptionStatus: Ref<CloudSubscriptionStatusResponse | null>
|
||||
telemetry: Pick<TelemetryProvider, 'trackMonthlySubscriptionCancelled'> | null
|
||||
shouldWatchCancellation: () => boolean
|
||||
@@ -20,7 +20,7 @@ type CancellationWatcherOptions = {
|
||||
|
||||
export function useSubscriptionCancellationWatcher({
|
||||
fetchStatus,
|
||||
isSubscriptionRequirementMet,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry,
|
||||
shouldWatchCancellation
|
||||
@@ -73,7 +73,7 @@ export function useSubscriptionCancellationWatcher({
|
||||
try {
|
||||
await fetchStatus()
|
||||
|
||||
if (!isSubscriptionRequirementMet.value) {
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!cancellationTracked.value) {
|
||||
cancellationTracked.value = true
|
||||
try {
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="image-preview outline-none group relative flex size-full min-h-16 min-w-16 flex-col px-2 justify-center"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@keydown="handleKeyDown"
|
||||
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2 justify-center"
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
class="h-full w-full overflow-hidden rounded-[5px] bg-node-component-surface relative"
|
||||
tabindex="0"
|
||||
role="img"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
:aria-busy="isLoading"
|
||||
class="h-full w-full overflow-hidden rounded-[5px] bg-muted-background relative"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
role="alert"
|
||||
class="flex size-full flex-col items-center justify-center bg-muted-background text-center text-base-foreground py-8"
|
||||
>
|
||||
<i
|
||||
@@ -48,11 +47,12 @@
|
||||
)
|
||||
"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover) -->
|
||||
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-2.5">
|
||||
<!-- Floating Action Buttons (appear on hover or focus) -->
|
||||
<div
|
||||
class="actions absolute top-2 right-2 flex gap-2.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"
|
||||
>
|
||||
<!-- Mask/Edit Button -->
|
||||
<button
|
||||
v-if="!hasMultipleImages"
|
||||
@@ -104,6 +104,7 @@
|
||||
v-for="(_, index) in imageUrls"
|
||||
:key="index"
|
||||
:class="getNavigationDotClass(index)"
|
||||
:aria-current="index === currentIndex ? 'true' : undefined"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
@@ -117,9 +118,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { useToast } from 'primevue'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { ShallowRef } from 'vue'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
@@ -146,18 +149,34 @@ const actionButtonClass =
|
||||
|
||||
// Component state
|
||||
const currentIndex = ref(0)
|
||||
const isHovered = ref(false)
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const currentImageEl = ref<HTMLImageElement>()
|
||||
const loadedUrls = new Set<string>()
|
||||
|
||||
// Computed values
|
||||
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
|
||||
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
|
||||
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
||||
|
||||
const { isLoading: imageIsLoading, error: imageError } = useImage(
|
||||
computed(() => ({ src: currentImageUrl.value }))
|
||||
)
|
||||
|
||||
// Only show loading if image is loading AND not already loaded in this batch
|
||||
const isLoading = computed(
|
||||
() => imageIsLoading.value && !loadedUrls.has(currentImageUrl.value)
|
||||
)
|
||||
|
||||
// Listen for keydown events from parent node
|
||||
const keyEvent = inject<ShallowRef<KeyboardEvent | null>>('keyEvent')
|
||||
|
||||
if (keyEvent) {
|
||||
watch(keyEvent, (e) => {
|
||||
if (!e) return
|
||||
handleKeyDown(e)
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for URL changes and reset state
|
||||
watch(
|
||||
() => props.imageUrls,
|
||||
@@ -166,11 +185,8 @@ watch(
|
||||
if (currentIndex.value >= newUrls.length) {
|
||||
currentIndex.value = 0
|
||||
}
|
||||
|
||||
// Reset loading and error states when URLs change
|
||||
actualDimensions.value = null
|
||||
imageError.value = false
|
||||
isLoading.value = newUrls.length > 0
|
||||
loadedUrls.clear()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -179,19 +195,12 @@ watch(
|
||||
const handleImageLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
isLoading.value = false
|
||||
imageError.value = false
|
||||
loadedUrls.add(currentImageUrl.value)
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
isLoading.value = false
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
|
||||
// In vueNodes mode, we need to set them manually before opening the mask editor.
|
||||
const setupNodeForMaskEditor = () => {
|
||||
if (!props.nodeId || !currentImageEl.value) return
|
||||
@@ -230,20 +239,9 @@ 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
|
||||
imageError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
}
|
||||
|
||||
const getNavigationDotClass = (index: number) => {
|
||||
return [
|
||||
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer p-0',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div
|
||||
v-else
|
||||
ref="nodeContainerRef"
|
||||
tabindex="0"
|
||||
:data-node-id="nodeData.id"
|
||||
:class="
|
||||
cn(
|
||||
@@ -16,7 +17,7 @@
|
||||
// hover (only when node should handle events)
|
||||
shouldHandleNodePointerEvents &&
|
||||
'hover:ring-7 ring-node-component-ring',
|
||||
'outline-transparent outline-2',
|
||||
'outline-transparent outline-2 focus-visible:outline-node-component-outline',
|
||||
borderClass,
|
||||
outlineClass,
|
||||
cursorClass,
|
||||
@@ -48,6 +49,7 @@
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.stop.prevent="handleDrop"
|
||||
@keydown="handleNodeKeydown"
|
||||
>
|
||||
<div class="flex flex-col justify-center items-center relative">
|
||||
<template v-if="isCollapsed">
|
||||
@@ -130,7 +132,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, onErrorCaptured, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onErrorCaptured,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
@@ -186,6 +197,14 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Provide keydown events to child components (ImagePreview, VideoPreview, etc.)
|
||||
const keyEvent = shallowRef<KeyboardEvent | null>(null)
|
||||
provide('keyEvent', keyEvent)
|
||||
|
||||
const handleNodeKeydown = (event: KeyboardEvent) => {
|
||||
keyEvent.value = event
|
||||
}
|
||||
|
||||
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
@@ -265,6 +284,8 @@ const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
|
||||
const { startDrag } = useNodeDrag()
|
||||
|
||||
async function nodeOnPointerdown(event: PointerEvent) {
|
||||
nodeContainerRef.value?.focus()
|
||||
|
||||
if (event.altKey && lgraphNode.value) {
|
||||
const result = LGraphCanvas.cloneNodes([lgraphNode.value])
|
||||
if (result?.created?.length) {
|
||||
|
||||
@@ -43,8 +43,8 @@ const singleFilterOption = computed(() => filterOptions.length === 1)
|
||||
<IconTextButton
|
||||
v-if="isUploadButtonEnabled && singleFilterOption"
|
||||
:label="$t('g.import')"
|
||||
class="ml-auto text-base-foreground hover:bg-node-component-widget-input-surface"
|
||||
type="transparent"
|
||||
class="ml-auto"
|
||||
type="secondary"
|
||||
@click="showUploadDialog"
|
||||
>
|
||||
<template #icon>
|
||||
|
||||
@@ -676,17 +676,11 @@ export class ComfyApp {
|
||||
'Payment Required: Please add credits to your account to use this node.'
|
||||
)
|
||||
) {
|
||||
if (!isCloud) {
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
if (isActiveSubscription.value) {
|
||||
useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
} else {
|
||||
const { isSubscriptionRequirementMet } = useSubscription()
|
||||
if (isSubscriptionRequirementMet.value) {
|
||||
useDialogService().showTopUpCreditsDialog({
|
||||
isInsufficientCredits: true
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
useDialogService().showExecutionErrorDialog(detail)
|
||||
|
||||
@@ -17,6 +17,7 @@ import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.
|
||||
import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -376,16 +377,11 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showTopUpCreditsDialog(options?: {
|
||||
function showTopUpCreditsDialog(options?: {
|
||||
isInsufficientCredits?: boolean
|
||||
}) {
|
||||
if (isCloud) {
|
||||
const { useSubscription } = await import(
|
||||
'@/platform/cloud/subscription/composables/useSubscription'
|
||||
)
|
||||
const { isSubscriptionRequirementMet } = useSubscription()
|
||||
if (!isSubscriptionRequirementMet.value) return
|
||||
}
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
if (!isActiveSubscription.value) return
|
||||
|
||||
return dialogStore.showDialog({
|
||||
key: 'top-up-credits',
|
||||
|
||||
@@ -119,6 +119,11 @@ export const useLitegraphService = () => {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
node.strokeStyles['nodeError'] = function (this: LGraphNode) {
|
||||
if (app.lastNodeErrors?.[this.id]?.errors) {
|
||||
return { color: 'red' }
|
||||
}
|
||||
}
|
||||
node.strokeStyles['dragOver'] = function (this: LGraphNode) {
|
||||
if (app.dragOverNode?.id == this.id) {
|
||||
return { color: 'dodgerblue' }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -32,7 +32,6 @@ import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface QueuedPrompt {
|
||||
/**
|
||||
@@ -535,97 +534,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
() => runningPromptIds.value.length
|
||||
)
|
||||
|
||||
/** Map of node errors indexed by locator ID. */
|
||||
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
|
||||
() => {
|
||||
if (!lastNodeErrors.value) return {}
|
||||
|
||||
const map: Record<NodeLocatorId, NodeError> = {}
|
||||
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
lastNodeErrors.value
|
||||
)) {
|
||||
const locatorId = executionIdToNodeLocatorId(executionId)
|
||||
if (locatorId) {
|
||||
map[locatorId] = nodeError
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
)
|
||||
|
||||
/** Get node errors by locator ID. */
|
||||
const getNodeErrors = (
|
||||
nodeLocatorId: NodeLocatorId
|
||||
): NodeError | undefined => {
|
||||
return nodeErrorsByLocatorId.value[nodeLocatorId]
|
||||
}
|
||||
|
||||
/** Check if a specific slot has validation errors. */
|
||||
const slotHasError = (
|
||||
nodeLocatorId: NodeLocatorId,
|
||||
slotName: string
|
||||
): boolean => {
|
||||
const nodeError = getNodeErrors(nodeLocatorId)
|
||||
if (!nodeError) return false
|
||||
|
||||
return nodeError.errors.some((e) => e.extra_info?.input_name === slotName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node and slot error flags when validation errors change.
|
||||
* Propagates errors up subgraph chains.
|
||||
*/
|
||||
watch(lastNodeErrors, () => {
|
||||
if (!app.graph || !app.graph.nodes) return
|
||||
|
||||
// Clear all error flags
|
||||
forEachNode(app.graph, (node) => {
|
||||
node.has_errors = false
|
||||
if (node.inputs) {
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!lastNodeErrors.value) return
|
||||
|
||||
// Set error flags on nodes and slots
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
lastNodeErrors.value
|
||||
)) {
|
||||
const node = getNodeByExecutionId(app.graph, executionId)
|
||||
if (!node) continue
|
||||
|
||||
node.has_errors = true
|
||||
|
||||
// Mark input slots with errors
|
||||
if (node.inputs) {
|
||||
for (const error of nodeError.errors) {
|
||||
const slotName = error.extra_info?.input_name
|
||||
if (!slotName) continue
|
||||
|
||||
const slot = node.inputs.find((s) => s.name === slotName)
|
||||
if (slot) {
|
||||
slot.hasErrors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate errors to parent subgraph nodes
|
||||
const parts = executionId.split(':')
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const parentExecutionId = parts.slice(0, i).join(':')
|
||||
const parentNode = getNodeByExecutionId(app.graph, parentExecutionId)
|
||||
if (parentNode) {
|
||||
parentNode.has_errors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isIdle,
|
||||
clientId,
|
||||
@@ -659,9 +567,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
// NodeLocatorId conversion helpers
|
||||
executionIdToNodeLocatorId,
|
||||
nodeLocatorIdToExecutionId,
|
||||
promptIdToWorkflowId,
|
||||
// Node error lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError
|
||||
promptIdToWorkflowId
|
||||
}
|
||||
})
|
||||
|
||||
@@ -60,3 +60,7 @@ export const isResultItemType = (
|
||||
): value is ResultItemType => {
|
||||
return value === 'input' || value === 'output' || value === 'temp'
|
||||
}
|
||||
|
||||
export function isStrings(types: unknown[]): types is string[] {
|
||||
return types.every((t) => typeof t === 'string')
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: vi.fn(() => ({
|
||||
isSubscriptionRequirementMet: { value: true },
|
||||
isActiveSubscription: vi.fn().mockReturnValue(true),
|
||||
showSubscriptionDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -7,7 +7,7 @@ import SubscriptionPanel from '@/platform/cloud/subscription/components/Subscrip
|
||||
|
||||
// Mock composables
|
||||
const mockSubscriptionData = {
|
||||
isSubscriptionRequirementMet: false,
|
||||
isActiveSubscription: false,
|
||||
isCancelled: false,
|
||||
formattedRenewalDate: '2024-12-31',
|
||||
formattedEndDate: '2024-12-31',
|
||||
@@ -120,14 +120,14 @@ describe('SubscriptionPanel', () => {
|
||||
|
||||
describe('subscription state functionality', () => {
|
||||
it('shows correct UI for active subscription', () => {
|
||||
mockSubscriptionData.isSubscriptionRequirementMet = 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', () => {
|
||||
mockSubscriptionData.isSubscriptionRequirementMet = false
|
||||
mockSubscriptionData.isActiveSubscription = false
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
|
||||
true
|
||||
@@ -137,14 +137,14 @@ describe('SubscriptionPanel', () => {
|
||||
})
|
||||
|
||||
it('shows renewal date for active non-cancelled subscription', () => {
|
||||
mockSubscriptionData.isSubscriptionRequirementMet = true
|
||||
mockSubscriptionData.isActiveSubscription = true
|
||||
mockSubscriptionData.isCancelled = false
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Renews 2024-12-31')
|
||||
})
|
||||
|
||||
it('shows expiry date for cancelled subscription', () => {
|
||||
mockSubscriptionData.isSubscriptionRequirementMet = true
|
||||
mockSubscriptionData.isActiveSubscription = true
|
||||
mockSubscriptionData.isCancelled = true
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Expires 2024-12-31')
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('useSubscription', () => {
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('should compute isSubscriptionRequirementMet correctly when subscription is active', async () => {
|
||||
it('should compute isActiveSubscription correctly when subscription is active', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -103,13 +103,13 @@ describe('useSubscription', () => {
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { isSubscriptionRequirementMet, fetchStatus } = useSubscription()
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
expect(isSubscriptionRequirementMet.value).toBe(true)
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should compute isSubscriptionRequirementMet as false when subscription is inactive', async () => {
|
||||
it('should compute isActiveSubscription as false when subscription is inactive', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -120,10 +120,10 @@ describe('useSubscription', () => {
|
||||
} as Response)
|
||||
|
||||
mockIsLoggedIn.value = true
|
||||
const { isSubscriptionRequirementMet, fetchStatus } = useSubscription()
|
||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
expect(isSubscriptionRequirementMet.value).toBe(false)
|
||||
expect(isActiveSubscription.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should format renewal date correctly', async () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
baseStatus
|
||||
)
|
||||
const isActive = ref(true)
|
||||
const isSubscriptionRequirementMet = computed(() => isActive.value)
|
||||
const isActiveSubscription = computed(() => isActive.value)
|
||||
|
||||
let shouldWatch = true
|
||||
const shouldWatchCancellation = () => shouldWatch
|
||||
@@ -76,7 +76,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isSubscriptionRequirementMet,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
@@ -106,7 +106,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isSubscriptionRequirementMet,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
@@ -128,7 +128,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isSubscriptionRequirementMet,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
@@ -153,7 +153,7 @@ describe('useSubscriptionCancellationWatcher', () => {
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isSubscriptionRequirementMet,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
|
||||
@@ -129,170 +129,3 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - Node Error Lookups', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
describe('getNodeErrors', () => {
|
||||
it('should return undefined when no errors exist', () => {
|
||||
const result = store.getNodeErrors('123')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return node error by locator ID for root graph node', () => {
|
||||
store.lastNodeErrors = {
|
||||
'123': {
|
||||
errors: [
|
||||
{
|
||||
type: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: 'Width must be positive',
|
||||
extra_info: { input_name: 'width' }
|
||||
}
|
||||
],
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.getNodeErrors('123')
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.errors).toHaveLength(1)
|
||||
expect(result?.errors[0].message).toBe('Invalid input')
|
||||
})
|
||||
|
||||
it('should return node error by locator ID for subgraph node', () => {
|
||||
const subgraphUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const mockSubgraph = {
|
||||
id: subgraphUuid,
|
||||
_nodes: []
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
} as any
|
||||
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
store.lastNodeErrors = {
|
||||
'123:456': {
|
||||
errors: [
|
||||
{
|
||||
type: 'validation_error',
|
||||
message: 'Invalid subgraph input',
|
||||
details: 'Missing required input',
|
||||
extra_info: { input_name: 'image' }
|
||||
}
|
||||
],
|
||||
class_type: 'SubgraphNode',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
const locatorId = `${subgraphUuid}:456`
|
||||
const result = store.getNodeErrors(locatorId)
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.errors[0].message).toBe('Invalid subgraph input')
|
||||
})
|
||||
})
|
||||
|
||||
describe('slotHasError', () => {
|
||||
it('should return false when node has no errors', () => {
|
||||
const result = store.slotHasError('123', 'width')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when node has errors but slot is not mentioned', () => {
|
||||
store.lastNodeErrors = {
|
||||
'123': {
|
||||
errors: [
|
||||
{
|
||||
type: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: 'Width must be positive',
|
||||
extra_info: { input_name: 'width' }
|
||||
}
|
||||
],
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.slotHasError('123', 'height')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when slot has error', () => {
|
||||
store.lastNodeErrors = {
|
||||
'123': {
|
||||
errors: [
|
||||
{
|
||||
type: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: 'Width must be positive',
|
||||
extra_info: { input_name: 'width' }
|
||||
}
|
||||
],
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.slotHasError('123', 'width')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when multiple errors exist for the same slot', () => {
|
||||
store.lastNodeErrors = {
|
||||
'123': {
|
||||
errors: [
|
||||
{
|
||||
type: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: 'Width must be positive',
|
||||
extra_info: { input_name: 'width' }
|
||||
},
|
||||
{
|
||||
type: 'validation_error',
|
||||
message: 'Invalid range',
|
||||
details: 'Width must be less than 1000',
|
||||
extra_info: { input_name: 'width' }
|
||||
}
|
||||
],
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.slotHasError('123', 'width')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle errors without extra_info', () => {
|
||||
store.lastNodeErrors = {
|
||||
'123': {
|
||||
errors: [
|
||||
{
|
||||
type: 'validation_error',
|
||||
message: 'General error',
|
||||
details: 'Something went wrong'
|
||||
}
|
||||
],
|
||||
class_type: 'TestNode',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.slotHasError('123', 'width')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user