mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
12 Commits
split/bill
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcbd119116 | ||
|
|
6923baafdb | ||
|
|
b79e7db5e0 | ||
|
|
53c6741db5 | ||
|
|
28f8f14005 | ||
|
|
8ac2c2088b | ||
|
|
ba1f9c2719 | ||
|
|
3afb8f0494 | ||
|
|
e366813388 | ||
|
|
1993bf4290 | ||
|
|
927f3f8541 | ||
|
|
979a832845 |
@@ -184,3 +184,45 @@ test.describe('App mode usage', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('App mode credits', () => {
|
||||
const API_PRICED_NODE = 'FluxProUltraImageNode'
|
||||
|
||||
test('shows the credit breakdown popover for priced nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeBadge.ShowApiPricing', true)
|
||||
await comfyPage.page.evaluate((type) => {
|
||||
const registered = window.LiteGraph!.registered_node_types[type] as {
|
||||
nodeData?: { price_badge?: unknown }
|
||||
}
|
||||
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
|
||||
registered.nodeData.price_badge = {
|
||||
engine: 'jsonata',
|
||||
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
}, API_PRICED_NODE)
|
||||
|
||||
await comfyPage.nodeOps.addNode(API_PRICED_NODE)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
|
||||
await expect(comfyPage.appMode.centerPanel).toBeVisible()
|
||||
|
||||
// The run/subscribe button flags that the workflow needs credits, even when
|
||||
// the pill collapses to its icon (kept in the accessible name).
|
||||
const runButton = comfyPage.appMode.runButton
|
||||
await expect(runButton).toBeVisible()
|
||||
await expect(runButton).toHaveAccessibleName(/Uses credits/)
|
||||
|
||||
// Hovering the button reveals the per-node credit breakdown.
|
||||
await runButton.hover()
|
||||
const breakdown = comfyPage.page.getByRole('list', {
|
||||
name: 'Credit breakdown by model'
|
||||
})
|
||||
await expect(breakdown).toBeVisible()
|
||||
await expect(breakdown).toContainText('99.9 credits/Run')
|
||||
await expect(
|
||||
comfyPage.page.getByText('Requires additional credits')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
@@ -28,6 +28,8 @@ const formatNumber = ({
|
||||
return new Intl.NumberFormat(locale, merged).format(value)
|
||||
}
|
||||
|
||||
export const CREDITS_ICON = 'icon-[lucide--coins]'
|
||||
|
||||
export const CREDITS_PER_USD = 211
|
||||
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
<i :class="CREDITS_ICON" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
@@ -29,7 +29,10 @@ import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const { textClass, showCreditsOnly } = defineProps<{
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 text-gold-500')" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4')" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4')" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
@@ -154,7 +154,11 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
creditsToUsd,
|
||||
usdToCredits
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'h-full bg-amber-400')" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
@@ -21,6 +21,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
|
||||
defineProps<{
|
||||
text: string
|
||||
rest?: string
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
:class="cn(CREDITS_ICON, 'size-3 text-amber-400')"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
@@ -134,6 +134,8 @@ import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
|
||||
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
|
||||
const {
|
||||
nodeDef,
|
||||
currentQuery,
|
||||
|
||||
@@ -105,7 +105,8 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/base/credits/comfyCredits', () => ({
|
||||
vi.mock('@/base/credits/comfyCredits', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
|
||||
}))
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
@@ -153,7 +153,12 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
|
||||
@@ -25,7 +25,8 @@ export const buttonVariants = cva({
|
||||
tertiary:
|
||||
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
|
||||
'brand-yellow': 'bg-brand-yellow text-black hover:bg-brand-yellow/85'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -58,7 +59,8 @@ const variants = [
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
'gradient',
|
||||
'brand-yellow'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = [
|
||||
'sm',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
@@ -223,7 +224,8 @@ const { t } = useI18n()
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
CREDITS_ICON,
|
||||
'size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -3696,7 +3696,7 @@
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
"mobileControls": "Edit & Run",
|
||||
"runCount": "Number of runs",
|
||||
"generations": "Generations",
|
||||
"rerun": "Rerun",
|
||||
"reuseParameters": "Reuse Parameters",
|
||||
"downloadAll": "Download {count} assets from this run",
|
||||
@@ -3708,6 +3708,9 @@
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"creditApproximateInfo": "Credit consumption shown here is approximate due to 3rd party provider calculations, exact values are available in your job history once the workflow is executed",
|
||||
"creditBreakdown": "Credit breakdown by model",
|
||||
"usesCredits": "Uses credits",
|
||||
"viewGraph": "View node graph",
|
||||
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
|
||||
"welcome": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
DEFAULT_TEAM_PLAN_STOP_INDEX,
|
||||
@@ -215,7 +216,8 @@ const { t } = useI18n()
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[comfy--credits] size-3 shrink-0',
|
||||
CREDITS_ICON,
|
||||
'size-3 shrink-0',
|
||||
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="flex items-baseline gap-2">
|
||||
<i class="icon-[lucide--component] size-4 self-center text-credit" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 self-center text-credit')" />
|
||||
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">{{
|
||||
$t('subscription.remaining')
|
||||
@@ -81,7 +81,7 @@
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
|
||||
<span class="@max-[180px]:hidden">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
@@ -133,7 +133,7 @@
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
|
||||
{{ displayPrepaid }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
import { CREDITS_ICON, formatCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -264,6 +264,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
|
||||
@@ -4,14 +4,20 @@
|
||||
value: buttonTooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
|
||||
variant="gradient"
|
||||
:class="
|
||||
cn(
|
||||
'subscribe-to-run-button gap-1.5 rounded-lg px-4 whitespace-nowrap [--credits-pill-base:var(--color-brand-yellow)]',
|
||||
large ? 'h-10 text-sm' : 'h-8'
|
||||
)
|
||||
"
|
||||
variant="brand-yellow"
|
||||
size="unset"
|
||||
data-testid="subscribe-to-run-button"
|
||||
@click="handleSubscribeToRun"
|
||||
>
|
||||
<i class="pi pi-lock" />
|
||||
{{ buttonLabel }}
|
||||
<slot name="trailing" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -20,13 +26,18 @@ import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
|
||||
const { large = false } = defineProps<{ large?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isMdOrLarger = breakpoints.greaterOrEqual('md')
|
||||
|
||||
@@ -43,11 +54,11 @@ const buttonLabel = computed(() => {
|
||||
: t('subscription.subscribeToRun')
|
||||
})
|
||||
|
||||
const buttonTooltip = computed(() =>
|
||||
canResubscribe.value
|
||||
? t('subscription.subscribeToRunFull')
|
||||
: t('subscription.inactive.memberRunTooltip')
|
||||
)
|
||||
const buttonTooltip = computed(() => {
|
||||
if (!canResubscribe.value) return t('subscription.inactive.memberRunTooltip')
|
||||
// md+ already shows the full label, so the matching tooltip would be redundant
|
||||
return isMdOrLarger.value ? undefined : t('subscription.subscribeToRunFull')
|
||||
})
|
||||
|
||||
function handleSubscribeToRun() {
|
||||
if (isCloud) {
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<!-- Credits Section -->
|
||||
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
@@ -220,7 +220,12 @@ import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/platform/workspace/components/WorkspaceSwitcherPopover.vue'
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
>
|
||||
@@ -303,6 +303,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
{{ $t(creditsRefillLabelKey) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
|
||||
<span class="font-bold text-base-foreground">
|
||||
{{ refillCredits }}
|
||||
</span>
|
||||
@@ -176,6 +176,8 @@ import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscript
|
||||
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
|
||||
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
|
||||
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,6 +97,9 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base-foreground">{{ refillLabel }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
|
||||
<span class="font-bold text-base-foreground">{{
|
||||
refillCredits
|
||||
}}</span>
|
||||
@@ -89,7 +89,7 @@
|
||||
{{ $t('subscription.preview.creditsRefillMonthlyTo') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
|
||||
<span class="font-bold text-base-foreground">{{
|
||||
monthlyRefillCredits
|
||||
}}</span>
|
||||
@@ -147,6 +147,9 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
|
||||
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 text-gold-500')" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4')" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-4')" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
@@ -154,7 +154,11 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
CREDITS_ICON,
|
||||
creditsToUsd,
|
||||
usdToCredits
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
<div class="mt-auto flex flex-col gap-1">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -244,7 +244,7 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -413,6 +413,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { I18nT, useI18n } from 'vue-i18n'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CreditSlider from '@/components/ui/credit-slider/CreditSlider.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
|
||||
25
src/renderer/extensions/linearMode/CreditsPill.vue
Normal file
25
src/renderer/extensions/linearMode/CreditsPill.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<span
|
||||
class="flex h-5 min-w-0 flex-wrap content-start items-center justify-end overflow-hidden text-xs font-medium [--pill-fill:color-mix(in_srgb,currentColor_15%,var(--credits-pill-base,var(--color-base-foreground)))]"
|
||||
>
|
||||
<span
|
||||
class="z-10 flex size-5 shrink-0 items-center justify-center rounded-full bg-(--pill-fill)"
|
||||
>
|
||||
<i :class="cn(CREDITS_ICON, 'size-3.5 bg-current')" />
|
||||
</span>
|
||||
<span
|
||||
class="-ml-2.5 flex h-5 items-center rounded-r-full bg-(--pill-fill) pr-2.5 pl-3"
|
||||
>
|
||||
{{ t('linearMode.usesCredits') }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { CreditBadge } from '@/renderer/extensions/linearMode/useCreditsSummary'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
@@ -20,6 +21,19 @@ const overlayMock = vi.hoisted(() => ({
|
||||
overlayTitle: 'Required input missing'
|
||||
}))
|
||||
|
||||
const creditsMock = vi.hoisted(() => ({
|
||||
badges: [] as CreditBadge[]
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/linearMode/useCreditsSummary', async () => {
|
||||
const { computed } = await import('vue')
|
||||
return {
|
||||
useCreditsSummary: () => ({
|
||||
creditsBadges: computed(() => creditsMock.badges)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billingMock.isActiveSubscription
|
||||
@@ -43,7 +57,8 @@ const i18n = createI18n({
|
||||
goto: 'Show errors in graph'
|
||||
},
|
||||
mobileNoWorkflow: 'No workflow',
|
||||
runCount: 'Run count',
|
||||
generations: 'Generations',
|
||||
usesCredits: 'Uses credits',
|
||||
viewJob: 'View job'
|
||||
},
|
||||
menu: {
|
||||
@@ -123,6 +138,7 @@ describe('LinearControls', () => {
|
||||
billingMock.isActiveSubscription = true
|
||||
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
|
||||
overlayMock.overlayTitle = 'Required input missing'
|
||||
creditsMock.badges = []
|
||||
})
|
||||
|
||||
it.for([
|
||||
@@ -195,6 +211,28 @@ describe('LinearControls', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
{ label: 'desktop', mobile: false },
|
||||
{ label: 'mobile', mobile: true }
|
||||
])(
|
||||
'flags a credit cost on the run button in $label controls',
|
||||
({ mobile }) => {
|
||||
creditsMock.badges = [
|
||||
{ title: 'Flux', price: '99.9 credits/Run', nodeId: toNodeId('1') }
|
||||
]
|
||||
|
||||
renderControls({ mobile })
|
||||
|
||||
expect(screen.getByText('Uses credits')).toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not flag a credit cost when no priced nodes are present', () => {
|
||||
renderControls()
|
||||
|
||||
expect(screen.queryByText('Uses credits')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show the warning when the error copy is empty', () => {
|
||||
overlayMock.overlayMessage = ''
|
||||
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
HoverCardContent,
|
||||
HoverCardPortal,
|
||||
HoverCardRoot,
|
||||
HoverCardTrigger,
|
||||
PopoverContent as RekaPopoverContent,
|
||||
PopoverPortal as RekaPopoverPortal,
|
||||
PopoverRoot as RekaPopoverRoot,
|
||||
PopoverTrigger as RekaPopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, toValue, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
@@ -15,9 +28,11 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import CreditsPill from '@/renderer/extensions/linearMode/CreditsPill.vue'
|
||||
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
|
||||
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import { useCreditsSummary } from '@/renderer/extensions/linearMode/useCreditsSummary'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -35,6 +50,7 @@ const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
|
||||
const { overlayMessage } = useErrorOverlayState()
|
||||
const { creditsBadges } = useCreditsSummary()
|
||||
|
||||
const { toastTo, mobile } = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
@@ -145,19 +161,35 @@ function handleDragDrop() {
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
<PartnerNodesList v-if="!mobile" />
|
||||
<section
|
||||
v-if="mobile"
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<SubscribeToRunButton v-if="!isActiveSubscription" class="mt-4 w-full">
|
||||
<template #trailing>
|
||||
<CreditsPill v-if="creditsBadges.length" class="ml-auto" />
|
||||
</template>
|
||||
</SubscribeToRunButton>
|
||||
<div v-else class="mt-4 flex">
|
||||
<PartnerNodesList mobile />
|
||||
<RekaPopoverRoot v-if="creditsBadges.length">
|
||||
<RekaPopoverTrigger as-child>
|
||||
<Button class="mr-2 size-10">
|
||||
<i :class="cn(CREDITS_ICON, 'bg-current')" />
|
||||
</Button>
|
||||
</RekaPopoverTrigger>
|
||||
<RekaPopoverPortal>
|
||||
<RekaPopoverContent
|
||||
side="top"
|
||||
:side-offset="8"
|
||||
:collision-padding="10"
|
||||
class="z-1700 w-72 max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
<PartnerNodesList :badges="creditsBadges" />
|
||||
</RekaPopoverContent>
|
||||
</RekaPopoverPortal>
|
||||
</RekaPopoverRoot>
|
||||
<Popover side="top" @open-auto-focus.prevent>
|
||||
<template #button>
|
||||
<Button size="lg" class="-mr-3 pr-7">
|
||||
@@ -166,19 +198,19 @@ function handleDragDrop() {
|
||||
</Button>
|
||||
</template>
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
class="m-1 mb-2 text-node-component-slot-text uppercase"
|
||||
v-text="t('linearMode.generations')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:aria-label="t('linearMode.generations')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-10 min-w-40"
|
||||
/>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="inverted"
|
||||
class="grow"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
@@ -190,45 +222,73 @@ function handleDragDrop() {
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
<CreditsPill
|
||||
v-if="creditsBadges.length"
|
||||
class="ml-auto text-brand-blue"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
:data-testid="linearRunButtonTestId"
|
||||
class="border-t border-node-component-border p-4 pb-6"
|
||||
>
|
||||
<section v-else :data-testid="linearRunButtonTestId" class="p-4 pb-6">
|
||||
<LinearRunErrorWarning v-if="showRunErrorWarning" />
|
||||
<div
|
||||
class="m-1 mb-2 text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-7 min-w-40"
|
||||
/>
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
<div class="mx-1 flex flex-wrap items-center justify-between gap-2">
|
||||
<div
|
||||
class="text-xs text-base-foreground uppercase"
|
||||
v-text="t('linearMode.generations')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.generations')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-7 min-w-40"
|
||||
/>
|
||||
</div>
|
||||
<HoverCardRoot
|
||||
:open-delay="creditsBadges.length ? 0 : 150"
|
||||
:close-delay="100"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
<HoverCardTrigger as-child>
|
||||
<SubscribeToRunButton
|
||||
v-if="!isActiveSubscription"
|
||||
large
|
||||
class="mt-4 w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<CreditsPill v-if="creditsBadges.length" class="ml-auto" />
|
||||
</template>
|
||||
</SubscribeToRunButton>
|
||||
<Button
|
||||
v-else
|
||||
variant="inverted"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
:aria-describedby="
|
||||
showRunErrorWarning
|
||||
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
|
||||
: undefined
|
||||
"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
<CreditsPill
|
||||
v-if="creditsBadges.length"
|
||||
class="ml-auto text-brand-blue"
|
||||
/>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal v-if="creditsBadges.length">
|
||||
<HoverCardContent
|
||||
side="top"
|
||||
:side-offset="10"
|
||||
:collision-padding="10"
|
||||
class="z-1700 w-(--reka-hover-card-trigger-width)"
|
||||
>
|
||||
<PartnerNodesList :badges="creditsBadges" />
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCardRoot>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
|
||||
|
||||
defineProps<{ title: string; price: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="not-last:mb-4">
|
||||
<div class="text-muted-foreground" v-text="title" />
|
||||
<li class="col-span-2 grid grid-cols-subgrid items-center text-xs/4.5">
|
||||
<span class="line-clamp-2 min-w-0 wrap-break-word" v-text="title" />
|
||||
<span
|
||||
class="mt-2 flex h-5 max-w-max items-center rounded-full bg-component-node-widget-background p-2 py-3"
|
||||
class="inline-flex shrink-0 items-center gap-1 justify-self-end rounded-full bg-current/15 px-1.5 py-1 text-xs/none font-medium"
|
||||
>
|
||||
<i class="mr-1 icon-[lucide--component] h-4 bg-amber-400" />
|
||||
<i :class="cn(CREDITS_ICON, 'size-3 shrink-0 bg-current')" />
|
||||
{{ price }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
46
src/renderer/extensions/linearMode/PartnerNodesList.test.ts
Normal file
46
src/renderer/extensions/linearMode/PartnerNodesList.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
import type { CreditBadge } from '@/renderer/extensions/linearMode/useCreditsSummary'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderList(badges: readonly CreditBadge[]) {
|
||||
return render(PartnerNodesList, {
|
||||
props: { badges },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('PartnerNodesList', () => {
|
||||
it('pairs each priced node title with its own credit cost, in order', () => {
|
||||
const badges: CreditBadge[] = [
|
||||
{
|
||||
title: 'Flux Pro Ultra',
|
||||
price: '99.9 credits/Run',
|
||||
nodeId: toNodeId(1)
|
||||
},
|
||||
{ title: 'Kling Video', price: '250 credits/Run', nodeId: toNodeId(2) }
|
||||
]
|
||||
renderList(badges)
|
||||
|
||||
const breakdown = screen.getByRole('list', {
|
||||
name: enMessages.linearMode.creditBreakdown
|
||||
})
|
||||
const rows = within(breakdown).getAllByRole('listitem')
|
||||
expect(rows).toHaveLength(badges.length)
|
||||
|
||||
badges.forEach(({ title, price }, i) => {
|
||||
expect(within(rows[i]).getByText(title)).toBeInTheDocument()
|
||||
expect(within(rows[i]).getByText(price)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
|
||||
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { app } from '@/scripts/app'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import type { CreditBadge } from '@/renderer/extensions/linearMode/useCreditsSummary'
|
||||
|
||||
defineProps<{ mobile?: boolean }>()
|
||||
defineProps<{ badges: readonly CreditBadge[] }>()
|
||||
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
const { t } = useI18n()
|
||||
|
||||
const creditsBadges = computed(() =>
|
||||
mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
if (!priceBadge) return
|
||||
|
||||
trackNodePrice(node)
|
||||
return [node.title, toValue(priceBadge).text, node.id] as const
|
||||
})
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<Popover v-if="mobile && creditsBadges.length" side="top">
|
||||
<template #button>
|
||||
<Button class="mr-2 size-10">
|
||||
<i class="icon-[comfy--credits] bg-amber-400" />
|
||||
</Button>
|
||||
</template>
|
||||
<section
|
||||
class="max-h-(--reka-popover-content-available-height) scroll-shadows-comfy-menu-bg overflow-y-auto"
|
||||
<div
|
||||
class="flex flex-col gap-2.5 rounded-xl border border-border-default bg-secondary-background p-3 text-base-foreground shadow-interface"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<p
|
||||
class="m-0 text-xs/snug font-semibold"
|
||||
v-text="t('linearMode.hasCreditCost')"
|
||||
/>
|
||||
<TooltipProvider :delay-duration="100">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<i
|
||||
class="icon-[lucide--info] size-3.5 shrink-0 cursor-help text-muted-foreground"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
:side-offset="4"
|
||||
:collision-padding="10"
|
||||
class="z-2000 max-w-64 rounded-md bg-base-foreground px-2 py-1.5 text-xs/snug text-base-background shadow-interface"
|
||||
>
|
||||
{{ t('linearMode.creditApproximateInfo') }}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<ul
|
||||
:aria-label="t('linearMode.creditBreakdown')"
|
||||
class="m-0 grid max-h-31 list-none grid-cols-[1fr_auto] gap-x-3 gap-y-2 overflow-y-auto pr-0.5 pl-0"
|
||||
>
|
||||
<PartnerNodeItem
|
||||
v-for="[title, price, key] in creditsBadges"
|
||||
:key
|
||||
:title
|
||||
:price
|
||||
v-for="badge in badges"
|
||||
:key="badge.nodeId"
|
||||
:title="badge.title"
|
||||
:price="badge.price"
|
||||
/>
|
||||
</section>
|
||||
</Popover>
|
||||
<div v-else-if="creditsBadges.length === 1">
|
||||
<PartnerNodeItem
|
||||
v-for="[title, price, key] in creditsBadges"
|
||||
:key
|
||||
:title
|
||||
:price
|
||||
class="border-t border-border-subtle pt-2"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<CollapsibleRoot
|
||||
v-else-if="creditsBadges.length"
|
||||
v-slot="{ open }"
|
||||
class="flex max-h-1/2 w-full flex-col"
|
||||
>
|
||||
<div class="mb-1 w-full border-b border-border-subtle" />
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button variant="textonly" class="w-full text-sm">
|
||||
<i class="icon-[comfy--credits] size-4 bg-amber-400" />
|
||||
{{ t('linearMode.hasCreditCost') }}
|
||||
<i v-if="open" class="ml-auto icon-[lucide--chevron-up]" />
|
||||
<i v-else class="ml-auto icon-[lucide--chevron-down]" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="scroll-shadows-comfy-menu-bg overflow-y-auto">
|
||||
<PartnerNodeItem
|
||||
v-for="[title, price, key] in creditsBadges"
|
||||
:key
|
||||
:title
|
||||
:price
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
|
||||
93
src/renderer/extensions/linearMode/useCreditsSummary.test.ts
Normal file
93
src/renderer/extensions/linearMode/useCreditsSummary.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCreditsSummary } from '@/renderer/extensions/linearMode/useCreditsSummary'
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
isGraphReady: true,
|
||||
graph: { nodes: [] as unknown[] }
|
||||
}))
|
||||
|
||||
const trackNodePrice = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: appMock }))
|
||||
|
||||
vi.mock('@/composables/node/usePriceBadge', () => ({
|
||||
usePriceBadge: () => ({
|
||||
isCreditsBadge: (badge: { credits?: boolean }) => badge?.credits === true
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/usePartitionedBadges',
|
||||
() => ({ trackNodePrice })
|
||||
)
|
||||
|
||||
interface FakeNode {
|
||||
id: string
|
||||
title: string
|
||||
badges: unknown[]
|
||||
isSubgraphNode: () => boolean
|
||||
subgraph?: { nodes: FakeNode[] }
|
||||
}
|
||||
|
||||
function node(id: string, title: string, badges: unknown[] = []): FakeNode {
|
||||
return { id, title, badges, isSubgraphNode: () => false }
|
||||
}
|
||||
|
||||
function subgraphNode(id: string, inner: FakeNode[] = []): FakeNode {
|
||||
return {
|
||||
id,
|
||||
title: 'group',
|
||||
badges: [],
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: { nodes: inner }
|
||||
}
|
||||
}
|
||||
|
||||
const creditsBadge = (text: string) => ({ text, credits: true })
|
||||
|
||||
beforeEach(() => {
|
||||
appMock.isGraphReady = true
|
||||
appMock.graph = { nodes: [] }
|
||||
trackNodePrice.mockClear()
|
||||
})
|
||||
|
||||
describe('useCreditsSummary', () => {
|
||||
it('returns nothing until the graph is ready', () => {
|
||||
appMock.isGraphReady = false
|
||||
appMock.graph = { nodes: [node('a', 'A', [creditsBadge('99')])] }
|
||||
|
||||
expect(useCreditsSummary().creditsBadges.value).toEqual([])
|
||||
})
|
||||
|
||||
it('summarizes only priced leaf nodes as title/price/nodeId', () => {
|
||||
appMock.graph = {
|
||||
nodes: [
|
||||
node('a', 'Flux', [creditsBadge('99 credits/Run')]),
|
||||
node('b', 'Free Node', [{ text: 'no cost' }]),
|
||||
subgraphNode('s', [node('c', 'Inner', [creditsBadge('12 credits')])])
|
||||
]
|
||||
}
|
||||
|
||||
expect(useCreditsSummary().creditsBadges.value).toEqual([
|
||||
{ title: 'Flux', price: '99 credits/Run', nodeId: 'a' },
|
||||
{ title: 'Inner', price: '12 credits', nodeId: 'c' }
|
||||
])
|
||||
})
|
||||
|
||||
it('tracks pricing dependencies only for the priced nodes', () => {
|
||||
appMock.graph = {
|
||||
nodes: [
|
||||
node('a', 'Flux', [creditsBadge('99')]),
|
||||
node('b', 'Free Node', [])
|
||||
]
|
||||
}
|
||||
|
||||
expect(useCreditsSummary().creditsBadges.value).toHaveLength(1)
|
||||
|
||||
expect(trackNodePrice).toHaveBeenCalledTimes(1)
|
||||
expect(trackNodePrice).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'a' })
|
||||
)
|
||||
})
|
||||
})
|
||||
41
src/renderer/extensions/linearMode/useCreditsSummary.ts
Normal file
41
src/renderer/extensions/linearMode/useCreditsSummary.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
export interface CreditBadge {
|
||||
title: string
|
||||
price: string
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the credit-cost badges of every priced node in the active graph,
|
||||
* feeding both the run/subscribe button pill and the breakdown popover.
|
||||
*/
|
||||
export function useCreditsSummary() {
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
|
||||
const creditsBadges = computed<CreditBadge[]>(() => {
|
||||
if (!app.isGraphReady) return []
|
||||
|
||||
return mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
if (!priceBadge) return
|
||||
|
||||
trackNodePrice(node)
|
||||
return {
|
||||
title: node.title,
|
||||
price: toValue(priceBadge).text,
|
||||
nodeId: node.id
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return { creditsBadges }
|
||||
}
|
||||
Reference in New Issue
Block a user