mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-19 02:06:38 +00:00
Compare commits
4 Commits
codex/part
...
codex/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81332a3dca | ||
|
|
d4b1ced906 | ||
|
|
d791351a1c | ||
|
|
922be31b74 |
@@ -83,8 +83,8 @@ const translations = {
|
||||
'如果您是 ComfyUI 新手,可以从 App 模式开始——这是工作流的简化视图。您随时可以切换回节点图视图以深入了解。'
|
||||
},
|
||||
'showcase.feature3.title': {
|
||||
en: 'Community Workflows on Comfy Hub',
|
||||
'zh-CN': 'Comfy Hub 上的社区工作流'
|
||||
en: 'Community Workflows on Comfy Workflows',
|
||||
'zh-CN': 'Comfy Workflows 上的社区工作流'
|
||||
},
|
||||
'showcase.feature3.description': {
|
||||
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template and customize it to your needs.',
|
||||
@@ -2272,7 +2272,7 @@ const translations = {
|
||||
en: 'Comfy Enterprise',
|
||||
'zh-CN': 'Comfy 企业版'
|
||||
},
|
||||
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
|
||||
'nav.comfyHub': { en: 'Comfy Workflows', 'zh-CN': 'Comfy Workflows' },
|
||||
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
|
||||
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
|
||||
@@ -4302,8 +4302,8 @@ const translations = {
|
||||
'zh-CN': '{count} 个工作流'
|
||||
},
|
||||
'models.list.contact.label': {
|
||||
en: 'COMFY HUB',
|
||||
'zh-CN': 'COMFY HUB'
|
||||
en: 'COMFY WORKFLOWS',
|
||||
'zh-CN': 'COMFY WORKFLOWS'
|
||||
},
|
||||
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
|
||||
'models.showcase.heading': {
|
||||
@@ -4339,9 +4339,9 @@ const translations = {
|
||||
'zh-CN': 'Wan 2.2\n文字转视频'
|
||||
},
|
||||
'models.list.contact.heading': {
|
||||
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
|
||||
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Workflows</a> for the newest workflows.',
|
||||
'zh-CN':
|
||||
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
|
||||
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Workflows</a> 查看最新工作流。'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
|
||||
@@ -44,7 +44,7 @@ const dirDescriptions: Record<string, string> = {
|
||||
}
|
||||
|
||||
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Workflows, ready to load and customize.`
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
@@ -87,7 +87,7 @@ const faqPage: JsonLdNode = {
|
||||
name: `How many ComfyUI workflows use ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Workflows. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
coverage:
|
||||
status:
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
||||
|
||||
comment:
|
||||
layout: 'header, diff, flags, files'
|
||||
behavior: default
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
--color-gold-500: #fdab34;
|
||||
--color-gold-600: #fd9903;
|
||||
|
||||
--color-credit: #fabc25;
|
||||
--color-credit: #ddbd31;
|
||||
|
||||
--color-coral-500: #f75951;
|
||||
--color-coral-600: #e04e48;
|
||||
@@ -140,26 +140,6 @@
|
||||
--button-surface: var(--color-white);
|
||||
--button-surface-contrast: var(--color-black);
|
||||
|
||||
--subscription-button-gradient:
|
||||
linear-gradient(
|
||||
315deg,
|
||||
rgb(105 230 255 / 0.15) 0%,
|
||||
rgb(99 73 233 / 0.5) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
70.71% 70.71% at 50% 50%,
|
||||
rgb(62 99 222 / 0.15) 0.01%,
|
||||
rgb(66 0 123 / 0.5) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
92deg,
|
||||
#d000ff 0.38%,
|
||||
#b009fe 37.07%,
|
||||
#3e1ffc 65.17%,
|
||||
#009dff 103.86%
|
||||
),
|
||||
linear-gradient(var(--color-button-surface, #2d2e32));
|
||||
|
||||
/* Code styling colors for help menu*/
|
||||
--code-text-color: rgb(0 122 255 / 1);
|
||||
--code-bg-color: rgb(96 165 250 / 0.2);
|
||||
|
||||
@@ -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="icon-[lucide--component] text-sm text-credit" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
@@ -48,7 +48,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="sm"
|
||||
data-testid="upgrade-to-add-credits-button"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
@@ -72,7 +72,7 @@
|
||||
:fluid="false"
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="sm"
|
||||
button-variant="gradient"
|
||||
button-variant="subscribe"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
class="mr-2 shrink-0 whitespace-nowrap"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="sm"
|
||||
data-testid="topbar-subscribe-button"
|
||||
@click="handleClick"
|
||||
|
||||
@@ -24,8 +24,8 @@ export const buttonVariants = cva({
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
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'
|
||||
subscribe:
|
||||
'border-transparent bg-credit text-charcoal-800 hover:opacity-80'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -58,7 +58,7 @@ const variants = [
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
'subscribe'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = [
|
||||
'sm',
|
||||
|
||||
@@ -132,7 +132,7 @@ export function useChart(
|
||||
const chartInstance = ref<Chart | null>(null)
|
||||
|
||||
function createChart() {
|
||||
if (!canvasRef.value) return
|
||||
if (canvasRef.value === null) return
|
||||
|
||||
chartInstance.value?.destroy()
|
||||
|
||||
|
||||
@@ -182,30 +182,6 @@ describe('useFeatureFlags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('partnerNodeGovernanceEnabled', () => {
|
||||
afterEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
it('uses the workspace eligibility flag', () => {
|
||||
remoteConfig.value = { partner_node_governance_enabled: true }
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
expect(flags.partnerNodeGovernanceEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false when the remote flag is unset', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(_path, defaultValue) => defaultValue
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
expect(flags.partnerNodeGovernanceEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -24,7 +24,6 @@ export enum ServerFeatureFlag {
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements',
|
||||
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
|
||||
@@ -135,13 +134,6 @@ export function useFeatureFlags() {
|
||||
cachedTeamWorkspacesEnabled
|
||||
)
|
||||
},
|
||||
get partnerNodeGovernanceEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
|
||||
remoteConfig.value.partner_node_governance_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.USER_SECRETS_ENABLED,
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
<div v-if="showActionButton" class="flex flex-col gap-3">
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="lg"
|
||||
class="w-full font-normal"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
|
||||
@@ -43,7 +43,7 @@ const label = computed(() =>
|
||||
v-if="hasInvalidNodes"
|
||||
class="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<i class="icon-[comfy--credits] bg-amber-400" />
|
||||
<i class="icon-[comfy--credits] bg-credit" />
|
||||
{{ t('actionbar.freeTierPartner') }}
|
||||
</div>
|
||||
<div v-else class="flex w-full items-center justify-between">
|
||||
|
||||
@@ -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="icon-[comfy--credits] size-4 shrink-0 bg-credit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Button
|
||||
:size
|
||||
:disabled="disabled"
|
||||
:variant="buttonVariant === 'gradient' ? 'gradient' : 'primary'"
|
||||
:variant="buttonVariant === 'subscribe' ? 'subscribe' : 'primary'"
|
||||
:class="cn('font-bold', fluid && 'w-full')"
|
||||
@click="handleSubscribe"
|
||||
>
|
||||
@@ -28,7 +28,7 @@ const {
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
size?: 'sm' | 'lg'
|
||||
buttonVariant?: 'default' | 'gradient'
|
||||
buttonVariant?: 'default' | 'subscribe'
|
||||
fluid?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
showDelay: 600
|
||||
}"
|
||||
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="unset"
|
||||
data-testid="subscribe-to-run-button"
|
||||
@click="handleSubscribeToRun"
|
||||
|
||||
@@ -107,7 +107,6 @@ export type RemoteConfig = {
|
||||
manager_survey_url?: string
|
||||
linear_toggle_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
partner_node_governance_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
node_library_essentials_enabled?: boolean
|
||||
free_tier_credits?: number
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getPartnerNodePolicy,
|
||||
PartnerNodePolicyApiError
|
||||
} from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
|
||||
const mockFetchApi = vi.fn()
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), init)
|
||||
}
|
||||
|
||||
describe('partnerNodePolicyApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes the configured policy response', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({
|
||||
enforcement_enabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).resolves.toEqual({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/workspace/partner-node-policy',
|
||||
{ cache: 'no-store' }
|
||||
)
|
||||
})
|
||||
|
||||
it('maps 404 to an unconfigured policy', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({}, { status: 404, statusText: 'Not Found' })
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('preserves non-404 status codes for policy decisions', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({}, { status: 503, statusText: 'Service Unavailable' })
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).rejects.toEqual(
|
||||
new PartnerNodePolicyApiError(503, 'Service Unavailable')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed policy responses', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({ enforcement_enabled: 'yes', nodes: [] })
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).rejects.toMatchObject({
|
||||
name: 'ZodError'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const partnerNodePolicyResponseSchema = z.object({
|
||||
enforcement_enabled: z.boolean(),
|
||||
nodes: z.record(z.string(), z.boolean())
|
||||
})
|
||||
|
||||
export interface PartnerNodePolicy {
|
||||
enforcementEnabled: boolean
|
||||
nodes: Readonly<Record<string, boolean>>
|
||||
}
|
||||
|
||||
export class PartnerNodePolicyApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'PartnerNodePolicyApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPartnerNodePolicy(): Promise<PartnerNodePolicy | null> {
|
||||
const response = await api.fetchApi('/workspace/partner-node-policy', {
|
||||
cache: 'no-store'
|
||||
})
|
||||
if (response.status === 404) return null
|
||||
if (!response.ok) {
|
||||
throw new PartnerNodePolicyApiError(response.status, response.statusText)
|
||||
}
|
||||
|
||||
const data = partnerNodePolicyResponseSchema.parse(await response.json())
|
||||
return {
|
||||
enforcementEnabled: data.enforcement_enabled,
|
||||
nodes: data.nodes
|
||||
}
|
||||
}
|
||||
@@ -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="icon-[lucide--component] text-sm text-credit" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
@@ -82,7 +82,7 @@
|
||||
<!-- Upgrade to add credits (free tier) -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && permissions.canTopUp && isFreeTier"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="sm"
|
||||
data-testid="upgrade-to-add-credits-button"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
@@ -110,7 +110,7 @@
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
"
|
||||
size="sm"
|
||||
button-variant="gradient"
|
||||
button-variant="subscribe"
|
||||
/>
|
||||
<Button
|
||||
v-if="
|
||||
|
||||
@@ -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="icon-[lucide--component] text-sm text-credit" />
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
>
|
||||
|
||||
@@ -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="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
|
||||
<span class="font-bold text-base-foreground">
|
||||
{{ refillCredits }}
|
||||
</span>
|
||||
|
||||
@@ -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="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
|
||||
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
|
||||
<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="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
|
||||
<span class="font-bold text-base-foreground">{{
|
||||
monthlyRefillCredits
|
||||
}}</span>
|
||||
|
||||
@@ -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="icon-[comfy--credits] size-4 shrink-0 bg-credit"
|
||||
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="icon-[comfy--credits] size-4 shrink-0 bg-credit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as PartnerNodePolicyApi from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { PartnerNodePolicyApiError } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
const mockGetPartnerNodePolicy = vi.hoisted(() => vi.fn())
|
||||
const mockFlags = vi.hoisted(() => ({
|
||||
teamWorkspacesEnabled: true,
|
||||
partnerNodeGovernanceEnabled: true
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/api/partnerNodePolicyApi',
|
||||
async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof PartnerNodePolicyApi>()
|
||||
return {
|
||||
...actual,
|
||||
getPartnerNodePolicy: mockGetPartnerNodePolicy
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function nodeDef(
|
||||
name: string,
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: `Display ${name}`,
|
||||
category: 'partner/image/Provider',
|
||||
python_module: 'comfy_api_nodes.provider',
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
api_node: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function activateWorkspace(id: string, type: 'personal' | 'team' = 'team') {
|
||||
const store = useTeamWorkspaceStore()
|
||||
store.workspaces = [
|
||||
{
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
role: 'owner',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null,
|
||||
subscriptionTier: null,
|
||||
members: [],
|
||||
pendingInvites: []
|
||||
}
|
||||
]
|
||||
store.activeWorkspaceId = id
|
||||
}
|
||||
|
||||
async function createLoadedStore() {
|
||||
const store = usePartnerNodeGovernanceStore()
|
||||
await vi.waitFor(() => expect(store.status).not.toBe('loading'))
|
||||
return store
|
||||
}
|
||||
|
||||
describe('partnerNodeGovernanceStore', () => {
|
||||
let store: ReturnType<typeof usePartnerNodeGovernanceStore> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.partnerNodeGovernanceEnabled = true
|
||||
mockGetPartnerNodePolicy.mockResolvedValue(null)
|
||||
activateWorkspace('workspace-one')
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
nodeDef('AllowedNode'),
|
||||
nodeDef('DisabledNode'),
|
||||
nodeDef('UnreviewedNode'),
|
||||
nodeDef('CoreNode', {
|
||||
api_node: false,
|
||||
category: 'sampling',
|
||||
python_module: 'nodes'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
store?.$dispose()
|
||||
store = undefined
|
||||
})
|
||||
|
||||
it('composes partner-node catalog metadata from object info', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
nodeDef('PartnerNode', {
|
||||
display_name: 'Partner Node',
|
||||
category: 'partner/video/Acme'
|
||||
}),
|
||||
nodeDef('CoreNode', { api_node: false })
|
||||
])
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.partnerNodes).toEqual([
|
||||
{ id: 'PartnerNode', name: 'Partner Node', provider: 'Acme' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats 404 as unconfigured and allows every node', async () => {
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.status).toBe('unconfigured')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not block nodes while enforcement is off', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows only explicit true rules while enforcement is on', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
|
||||
expect(store.isNodeDisabled('UnreviewedNode')).toBe(true)
|
||||
expect(store.isNodeDisabled('CoreNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed for a 503 from an enforcing workspace', async () => {
|
||||
mockGetPartnerNodePolicy.mockRejectedValue(
|
||||
new PartnerNodePolicyApiError(503, 'Service Unavailable')
|
||||
)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.status).toBe('unavailable')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
|
||||
expect(store.isNodeDisabled('CoreNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('fails open during initial loading and a generic failure', async () => {
|
||||
let rejectLoad!: (error: Error) => void
|
||||
mockGetPartnerNodePolicy.mockReturnValue(
|
||||
new Promise((_, reject) => {
|
||||
rejectLoad = reject
|
||||
})
|
||||
)
|
||||
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
|
||||
expect(store.status).toBe('loading')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
|
||||
rejectLoad(new Error('Network error'))
|
||||
await vi.waitFor(() => expect(store?.status).toBe('error'))
|
||||
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('stays fail-closed while retrying an unavailable policy', async () => {
|
||||
mockGetPartnerNodePolicy.mockRejectedValueOnce(
|
||||
new PartnerNodePolicyApiError(503, 'Service Unavailable')
|
||||
)
|
||||
store = await createLoadedStore()
|
||||
let rejectRetry!: (error: Error) => void
|
||||
mockGetPartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectRetry = reject
|
||||
})
|
||||
)
|
||||
|
||||
const retry = store.loadPolicy()
|
||||
|
||||
expect(store.status).toBe('unavailable')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
|
||||
rejectRetry(new Error('Network error'))
|
||||
await retry
|
||||
expect(store.status).toBe('unavailable')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the last enforcing policy after a generic refresh error', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
store = await createLoadedStore()
|
||||
mockGetPartnerNodePolicy.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await store.loadPolicy()
|
||||
|
||||
expect(store.status).toBe('error')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
|
||||
})
|
||||
|
||||
it('stays inactive when partner-node governance is disabled', async () => {
|
||||
mockFlags.partnerNodeGovernanceEnabled = false
|
||||
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await nextTick()
|
||||
|
||||
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
|
||||
expect(store.status).toBe('inactive')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('stays inactive in a personal workspace', async () => {
|
||||
activateWorkspace('personal-workspace', 'personal')
|
||||
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await nextTick()
|
||||
|
||||
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
|
||||
expect(store.status).toBe('inactive')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a stale response after switching workspaces', async () => {
|
||||
let resolveFirst!: (policy: PartnerNodePolicy) => void
|
||||
mockGetPartnerNodePolicy
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
enforcementEnabled: true,
|
||||
nodes: { DisabledNode: true }
|
||||
} satisfies PartnerNodePolicy)
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await vi.waitFor(() =>
|
||||
expect(mockGetPartnerNodePolicy).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() => expect(store?.status).toBe('configured'))
|
||||
resolveFirst({
|
||||
enforcementEnabled: true,
|
||||
nodes: { DisabledNode: false }
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(store.governedWorkspaceId).toBe('workspace-two')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
getPartnerNodePolicy,
|
||||
PartnerNodePolicyApiError
|
||||
} from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
export interface PartnerNodeCatalogItem {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type PartnerNodePolicyStatus =
|
||||
| 'inactive'
|
||||
| 'loading'
|
||||
| 'unconfigured'
|
||||
| 'configured'
|
||||
| 'unavailable'
|
||||
| 'error'
|
||||
|
||||
export const usePartnerNodeGovernanceStore = defineStore(
|
||||
'partnerNodeGovernance',
|
||||
() => {
|
||||
const { flags } = useFeatureFlags()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const policy = shallowRef<PartnerNodePolicy | null>(null)
|
||||
const policyWorkspaceId = ref<string | null>(null)
|
||||
const status = ref<PartnerNodePolicyStatus>('inactive')
|
||||
const error = shallowRef<Error | null>(null)
|
||||
let loadVersion = 0
|
||||
|
||||
const governedWorkspaceId = computed(() => {
|
||||
const workspace = workspaceStore.activeWorkspace
|
||||
return flags.teamWorkspacesEnabled &&
|
||||
flags.partnerNodeGovernanceEnabled &&
|
||||
workspace?.type === 'team'
|
||||
? workspace.id
|
||||
: null
|
||||
})
|
||||
|
||||
const partnerNodes = computed<PartnerNodeCatalogItem[]>(() =>
|
||||
Object.values(nodeDefStore.nodeDefsByName)
|
||||
.filter((nodeDef) => nodeDef.api_node)
|
||||
.map((nodeDef) => ({
|
||||
id: nodeDef.name,
|
||||
name: nodeDef.display_name || nodeDef.name,
|
||||
provider:
|
||||
nodeDef.category.split('/')[2] ||
|
||||
nodeDef.category ||
|
||||
nodeDef.python_module
|
||||
}))
|
||||
)
|
||||
|
||||
function isNodeDisabled(nodeType: string): boolean {
|
||||
if (!nodeDefStore.nodeDefsByName[nodeType]?.api_node) return false
|
||||
const workspaceId = governedWorkspaceId.value
|
||||
if (!workspaceId || policyWorkspaceId.value !== workspaceId) return false
|
||||
if (status.value === 'unavailable') return true
|
||||
return (
|
||||
policy.value?.enforcementEnabled === true &&
|
||||
policy.value.nodes[nodeType] !== true
|
||||
)
|
||||
}
|
||||
|
||||
async function loadPolicy(): Promise<void> {
|
||||
const workspaceId = governedWorkspaceId.value
|
||||
const version = ++loadVersion
|
||||
if (!workspaceId) {
|
||||
policy.value = null
|
||||
policyWorkspaceId.value = null
|
||||
status.value = 'inactive'
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceChanged = policyWorkspaceId.value !== workspaceId
|
||||
if (workspaceChanged) {
|
||||
policy.value = null
|
||||
policyWorkspaceId.value = workspaceId
|
||||
status.value = 'loading'
|
||||
} else if (status.value !== 'unavailable') {
|
||||
status.value = 'loading'
|
||||
}
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const nextPolicy = await getPartnerNodePolicy()
|
||||
if (
|
||||
version !== loadVersion ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return
|
||||
}
|
||||
policy.value = nextPolicy
|
||||
status.value = nextPolicy ? 'configured' : 'unconfigured'
|
||||
} catch (loadError) {
|
||||
if (
|
||||
version !== loadVersion ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return
|
||||
}
|
||||
error.value =
|
||||
loadError instanceof Error
|
||||
? loadError
|
||||
: new Error('Failed to load partner node policy')
|
||||
if (
|
||||
loadError instanceof PartnerNodePolicyApiError &&
|
||||
loadError.status === 503
|
||||
) {
|
||||
policy.value = null
|
||||
status.value = 'unavailable'
|
||||
return
|
||||
}
|
||||
if (status.value !== 'unavailable') status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
watch(governedWorkspaceId, () => void loadPolicy(), { immediate: true })
|
||||
|
||||
return {
|
||||
policy,
|
||||
status,
|
||||
error,
|
||||
governedWorkspaceId,
|
||||
partnerNodes,
|
||||
isNodeDisabled,
|
||||
loadPolicy
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -7,7 +7,7 @@ defineProps<{ title: string; price: string }>()
|
||||
<span
|
||||
class="mt-2 flex h-5 max-w-max items-center rounded-full bg-component-node-widget-background p-2 py-3"
|
||||
>
|
||||
<i class="mr-1 icon-[lucide--component] h-4 bg-amber-400" />
|
||||
<i class="mr-1 icon-[lucide--component] h-4 bg-credit" />
|
||||
{{ price }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ const { t } = useI18n()
|
||||
<Popover v-if="mobile && creditsBadges.length" side="top">
|
||||
<template #button>
|
||||
<Button class="mr-2 size-10">
|
||||
<i class="icon-[comfy--credits] bg-amber-400" />
|
||||
<i class="icon-[comfy--credits] bg-credit" />
|
||||
</Button>
|
||||
</template>
|
||||
<section
|
||||
@@ -51,7 +51,7 @@ const { t } = useI18n()
|
||||
<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" />
|
||||
<i class="icon-[comfy--credits] size-4 bg-credit" />
|
||||
{{ t('linearMode.hasCreditCost') }}
|
||||
<i v-if="open" class="ml-auto icon-[lucide--chevron-up]" />
|
||||
<i v-else class="ml-auto icon-[lucide--chevron-down]" />
|
||||
|
||||
Reference in New Issue
Block a user