mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
2 Commits
fix/i18n-n
...
prototype/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd24e83ef1 | ||
|
|
08461c6c49 |
@@ -12,6 +12,7 @@ import { computed, onMounted, watch } from 'vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { installPartnerNodePrototype } from '@/platform/workspace/partnerNodePrototype/installPartnerNodePrototype'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
@@ -21,6 +22,8 @@ import { useConflictDetection } from '@/workbench/extensions/manager/composables
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
installPartnerNodePrototype()
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
|
||||
|
||||
@@ -1566,6 +1566,7 @@
|
||||
"Workspace": "Workspace",
|
||||
"Error System": "Error System",
|
||||
"Other": "Other",
|
||||
"PartnerNodes": "Partner Nodes Prototype",
|
||||
"Secrets": "Secrets",
|
||||
"Node Library": "Node Library",
|
||||
"PointCloud": "Point Cloud"
|
||||
@@ -2854,6 +2855,20 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"partnerNodes": {
|
||||
"title": "Partner Nodes Prototype",
|
||||
"description": "Preview which partner nodes appear in the modern node library and search.",
|
||||
"prototypeTitle": "Local UX prototype",
|
||||
"prototypeDescription": "Changes are stored only in this browser for the active workspace. They do not enforce node insertion, templates, or backend execution.",
|
||||
"searchPlaceholder": "Search partner nodes",
|
||||
"enableFiltered": "Enable filtered",
|
||||
"disableFiltered": "Disable filtered",
|
||||
"enabledCount": "{enabled} of {total} enabled",
|
||||
"empty": "No partner nodes are available on this server.",
|
||||
"noResults": "No partner nodes match this search.",
|
||||
"toggleLabel": "Show {node} in modern node discovery",
|
||||
"defaultDeny": "Newly discovered partner nodes start disabled in this prototype."
|
||||
},
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
@@ -4393,7 +4408,9 @@
|
||||
"hideDevOnly": "Hide Dev-Only Nodes",
|
||||
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
|
||||
"hideSubgraph": "Hide Subgraph Nodes",
|
||||
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
|
||||
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search",
|
||||
"hidePrototypeDisabledPartnerNodes": "Hide Prototype-Disabled Partner Nodes",
|
||||
"hidePrototypeDisabledPartnerNodesDescription": "Hides partner nodes disabled in the local UX prototype from modern node discovery"
|
||||
},
|
||||
"secrets": {
|
||||
"title": "API Keys & Secrets",
|
||||
|
||||
@@ -19,7 +19,9 @@ const env = vi.hoisted(() => {
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy' as 'legacy' | 'workspace'
|
||||
billingType: 'legacy' as 'legacy' | 'workspace',
|
||||
workspaceType: 'personal' as 'personal' | 'team',
|
||||
workspaceRole: 'owner' as 'owner' | 'member'
|
||||
}
|
||||
const fakeRef = <K extends keyof typeof state>(key: K) => ({
|
||||
get value() {
|
||||
@@ -70,6 +72,13 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
workspaceType: env.fakeRef('workspaceType'),
|
||||
workspaceRole: env.fakeRef('workspaceRole')
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(),
|
||||
getSettingInfo: vi.fn()
|
||||
@@ -116,7 +125,9 @@ describe('useSettingUI', () => {
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy'
|
||||
billingType: 'legacy',
|
||||
workspaceType: 'personal',
|
||||
workspaceRole: 'owner'
|
||||
})
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
@@ -180,6 +191,27 @@ describe('useSettingUI', () => {
|
||||
expect(defaultCategory.value.key).toBe('about')
|
||||
})
|
||||
|
||||
it('shows the partner-node prototype only to team workspace owners', () => {
|
||||
Object.assign(env.state, {
|
||||
isCloud: true,
|
||||
isLoggedIn: true,
|
||||
teamWorkspacesEnabled: true,
|
||||
workspaceType: 'team',
|
||||
workspaceRole: 'owner'
|
||||
})
|
||||
|
||||
const ownerNavIds = useSettingUI().navGroups.value.flatMap((group) =>
|
||||
group.items.map((item) => item.id)
|
||||
)
|
||||
expect(ownerNavIds).toContain('workspace-partner-nodes')
|
||||
|
||||
env.state.workspaceRole = 'member'
|
||||
const memberNavIds = useSettingUI().navGroups.value.flatMap((group) =>
|
||||
group.items.map((item) => item.id)
|
||||
)
|
||||
expect(memberNavIds).not.toContain('workspace-partner-nodes')
|
||||
})
|
||||
|
||||
describe('legacy billing in the workspace layout', () => {
|
||||
const navKeys = (groups: { items: { id: string }[] }[]) =>
|
||||
groups.flatMap((group) => group.items.map((item) => item.id))
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type { NavGroupData } from '@/types/navTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
@@ -28,6 +29,7 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
LiteGraph: 'icon-[lucide--workflow]',
|
||||
'Mask Editor': 'icon-[lucide--pen-tool]',
|
||||
Other: 'icon-[lucide--ellipsis]',
|
||||
PartnerNodes: 'icon-[lucide--shield-check]',
|
||||
PlanCredits: 'icon-[lucide--credit-card]',
|
||||
secrets: 'icon-[lucide--key-round]',
|
||||
'server-config': 'icon-[lucide--server]',
|
||||
@@ -54,6 +56,7 @@ export function useSettingUI(
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription, type: billingType } = useBillingContext()
|
||||
const { workspaceRole, workspaceType } = useWorkspaceUI()
|
||||
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
@@ -192,6 +195,25 @@ export function useSettingUI(
|
||||
() => teamWorkspacesEnabled.value && isLoggedIn.value
|
||||
)
|
||||
|
||||
const shouldShowPartnerNodesPrototype = computed(
|
||||
() =>
|
||||
shouldShowWorkspacePanel.value &&
|
||||
workspaceType.value === 'team' &&
|
||||
workspaceRole.value === 'owner'
|
||||
)
|
||||
|
||||
const partnerNodesPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-partner-nodes',
|
||||
label: 'PartnerNodes',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const secretsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'secrets',
|
||||
@@ -245,6 +267,7 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowPartnerNodesPrototype.value ? [partnerNodesPanel] : []),
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
@@ -296,6 +319,9 @@ export function useSettingUI(
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
...(shouldShowPartnerNodesPrototype.value
|
||||
? [partnerNodesPanel.node]
|
||||
: []),
|
||||
...(isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
|
||||
@@ -87,3 +87,4 @@ export type SettingPanelType =
|
||||
| 'subscription'
|
||||
| 'user'
|
||||
| 'workspace'
|
||||
| 'workspace-partner-nodes'
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import PartnerNodesPanelContent from './PartnerNodesPanelContent.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { clear: 'Clear' },
|
||||
workspacePanel: {
|
||||
partnerNodes: {
|
||||
title: 'Partner Nodes Prototype',
|
||||
description: 'Preview node discovery.',
|
||||
prototypeTitle: 'Local UX prototype',
|
||||
prototypeDescription:
|
||||
'Changes are stored only in this browser for the active workspace.',
|
||||
searchPlaceholder: 'Search partner nodes',
|
||||
enableFiltered: 'Enable filtered',
|
||||
disableFiltered: 'Disable filtered',
|
||||
enabledCount: '{enabled} of {total} enabled',
|
||||
empty: 'No partner nodes are available.',
|
||||
noResults: 'No partner nodes match this search.',
|
||||
toggleLabel: 'Show {node} in modern node discovery',
|
||||
defaultDeny: 'New partner nodes start disabled.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function createPartnerNode(
|
||||
name: string,
|
||||
displayName: string,
|
||||
provider: string
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: displayName,
|
||||
category: `api/${provider}`,
|
||||
python_module: 'test',
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false,
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
|
||||
function activateTeamWorkspace() {
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
workspaceStore.workspaces = [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
name: 'Acme',
|
||||
type: 'team',
|
||||
role: 'owner',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null,
|
||||
subscriptionTier: null,
|
||||
members: [],
|
||||
pendingInvites: []
|
||||
}
|
||||
]
|
||||
workspaceStore.activeWorkspaceId = 'workspace-1'
|
||||
}
|
||||
|
||||
describe('PartnerNodesPanelContent', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
const pinia = createTestingPinia({ stubActions: false })
|
||||
setActivePinia(pinia)
|
||||
activateTeamWorkspace()
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createPartnerNode('OpenAIImage', 'OpenAI Image', 'OpenAI'),
|
||||
createPartnerNode('AdobeFirefly', 'Adobe Firefly', 'Adobe')
|
||||
])
|
||||
})
|
||||
|
||||
it('explains the prototype boundary and enables filtered nodes', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(PartnerNodesPanelContent, {
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Local UX prototype')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Changes are stored only in this browser for the active workspace.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
const search = screen.getByRole('combobox')
|
||||
await user.type(search, 'OpenAI')
|
||||
|
||||
expect(screen.getByText('OpenAI Image')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Adobe Firefly')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Enable filtered' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Show OpenAI Image in modern node discovery'
|
||||
})
|
||||
).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col gap-4">
|
||||
<header>
|
||||
<h2 class="m-0 text-2xl font-semibold text-base-foreground">
|
||||
{{ $t('workspacePanel.partnerNodes.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 mb-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.partnerNodes.description') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
role="note"
|
||||
class="flex gap-3 rounded-lg bg-secondary-background p-3 text-sm text-base-foreground"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--flask-conical] size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p class="m-0 font-semibold">
|
||||
{{ $t('workspacePanel.partnerNodes.prototypeTitle') }}
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-muted-foreground">
|
||||
{{ $t('workspacePanel.partnerNodes.prototypeDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
|
||||
class="min-w-64 flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:disabled="!filteredNodes.length"
|
||||
@click="setAllFiltered(true)"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.enableFiltered') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:disabled="!filteredNodes.length"
|
||||
@click="setAllFiltered(false)"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.disableFiltered') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!prototypeStore.partnerNodes.length"
|
||||
class="my-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.empty') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else-if="!filteredNodes.length"
|
||||
class="my-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.noResults') }}
|
||||
</p>
|
||||
|
||||
<div v-else class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<section
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.provider"
|
||||
class="mb-4 overflow-hidden rounded-lg border border-interface-stroke"
|
||||
>
|
||||
<h3
|
||||
class="m-0 flex items-center justify-between bg-secondary-background px-4 py-2 text-sm font-semibold text-base-foreground"
|
||||
>
|
||||
<span>{{ group.provider }}</span>
|
||||
<span class="font-normal text-muted-foreground">
|
||||
{{
|
||||
$t('workspacePanel.partnerNodes.enabledCount', {
|
||||
enabled: group.enabledCount,
|
||||
total: group.nodes.length
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</h3>
|
||||
<ul class="m-0 list-none divide-y divide-interface-stroke p-0">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="flex min-h-12 items-center justify-between gap-4 px-4 py-1"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 truncate text-sm text-base-foreground">
|
||||
{{ node.displayName }}
|
||||
</p>
|
||||
<p class="m-0 truncate text-xs text-muted-foreground">
|
||||
{{ node.name }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="prototypeStore.isEnabled(node.name)"
|
||||
:aria-label="
|
||||
$t('workspacePanel.partnerNodes.toggleLabel', {
|
||||
node: node.displayName
|
||||
})
|
||||
"
|
||||
class="group focus-visible:ring-ring flex h-11 w-14 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent focus-visible:ring-1 focus-visible:outline-none"
|
||||
@click="toggleNode(node.name)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'relative h-6 w-11 rounded-full border border-interface-stroke transition-colors',
|
||||
prototypeStore.isEnabled(node.name)
|
||||
? 'bg-primary-background group-hover:bg-primary-background-hover'
|
||||
: 'bg-secondary-background group-hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm transition-transform',
|
||||
prototypeStore.isEnabled(node.name) && 'translate-x-5'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-xs text-muted-foreground">
|
||||
{{ $t('workspacePanel.partnerNodes.defaultDeny') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import type { PartnerNodePrototypeEntry } from '@/platform/workspace/partnerNodePrototype/partnerNodePrototypeStore'
|
||||
import { usePartnerNodePrototypeStore } from '@/platform/workspace/partnerNodePrototype/partnerNodePrototypeStore'
|
||||
|
||||
interface PartnerNodePrototypeGroup {
|
||||
provider: string
|
||||
nodes: PartnerNodePrototypeEntry[]
|
||||
enabledCount: number
|
||||
}
|
||||
|
||||
const prototypeStore = usePartnerNodePrototypeStore()
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (!query) return prototypeStore.partnerNodes
|
||||
return prototypeStore.partnerNodes.filter(
|
||||
(node) =>
|
||||
node.displayName.toLowerCase().includes(query) ||
|
||||
node.name.toLowerCase().includes(query) ||
|
||||
node.provider.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const groupedNodes = computed<PartnerNodePrototypeGroup[]>(() => {
|
||||
const nodesByProvider = new Map<string, PartnerNodePrototypeEntry[]>()
|
||||
for (const node of filteredNodes.value) {
|
||||
const nodes = nodesByProvider.get(node.provider) ?? []
|
||||
nodes.push(node)
|
||||
nodesByProvider.set(node.provider, nodes)
|
||||
}
|
||||
return [...nodesByProvider.entries()]
|
||||
.sort(([first], [second]) => first.localeCompare(second))
|
||||
.map(([provider, nodes]) => ({
|
||||
provider,
|
||||
nodes: nodes.toSorted((first, second) =>
|
||||
first.displayName.localeCompare(second.displayName)
|
||||
),
|
||||
enabledCount: nodes.filter((node) => prototypeStore.isEnabled(node.name))
|
||||
.length
|
||||
}))
|
||||
})
|
||||
|
||||
function setAllFiltered(enabled: boolean) {
|
||||
prototypeStore.setEnabled(
|
||||
filteredNodes.value.map((node) => node.name),
|
||||
enabled
|
||||
)
|
||||
}
|
||||
|
||||
function toggleNode(nodeType: string) {
|
||||
prototypeStore.setEnabled([nodeType], !prototypeStore.isEnabled(nodeType))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { installPartnerNodePrototype } from './installPartnerNodePrototype'
|
||||
import { usePartnerNodePrototypeStore } from './partnerNodePrototypeStore'
|
||||
|
||||
function createNodeDef(name: string, apiNode: boolean = false): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
category: apiNode ? 'api/OpenAI' : 'image',
|
||||
python_module: 'test',
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false,
|
||||
api_node: apiNode
|
||||
}
|
||||
}
|
||||
|
||||
function activateTeamWorkspace() {
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
workspaceStore.workspaces = [
|
||||
{
|
||||
id: 'workspace-1',
|
||||
name: 'Acme',
|
||||
type: 'team',
|
||||
role: 'owner',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null,
|
||||
subscriptionTier: null,
|
||||
members: [],
|
||||
pendingInvites: []
|
||||
}
|
||||
]
|
||||
workspaceStore.activeWorkspaceId = 'workspace-1'
|
||||
}
|
||||
|
||||
describe('installPartnerNodePrototype', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
activateTeamWorkspace()
|
||||
})
|
||||
|
||||
it('defaults partner nodes to hidden and reveals enabled nodes', () => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
nodeDefStore.updateNodeDefs([
|
||||
createNodeDef('LoadImage'),
|
||||
createNodeDef('OpenAIImage', true)
|
||||
])
|
||||
|
||||
installPartnerNodePrototype()
|
||||
|
||||
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
|
||||
'LoadImage'
|
||||
])
|
||||
|
||||
usePartnerNodePrototypeStore().setEnabled(['OpenAIImage'], true)
|
||||
|
||||
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
|
||||
'LoadImage',
|
||||
'OpenAIImage'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not change discovery outside an owned team workspace', () => {
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const workspace = workspaceStore.workspaces[0]
|
||||
if (!workspace) throw new Error('Expected a workspace fixture')
|
||||
workspaceStore.workspaces = [{ ...workspace, role: 'member' }]
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
nodeDefStore.updateNodeDefs([createNodeDef('OpenAIImage', true)])
|
||||
installPartnerNodePrototype()
|
||||
|
||||
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
|
||||
'OpenAIImage'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { usePartnerNodePrototypeStore } from './partnerNodePrototypeStore'
|
||||
|
||||
export function installPartnerNodePrototype() {
|
||||
const prototypeStore = usePartnerNodePrototypeStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
nodeDefStore.registerNodeDefFilter({
|
||||
id: 'prototype.partnerNodeVisibility',
|
||||
name: t('nodeFilters.hidePrototypeDisabledPartnerNodes'),
|
||||
description: t('nodeFilters.hidePrototypeDisabledPartnerNodesDescription'),
|
||||
predicate: (nodeDef) => !prototypeStore.disabledNodeTypes.has(nodeDef.name)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getProviderName } from '@/utils/categoryUtil'
|
||||
|
||||
export interface PartnerNodePrototypeEntry {
|
||||
name: string
|
||||
displayName: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface PersistedPrototypeState {
|
||||
enabledNodeTypesByWorkspace: Record<string, Record<string, boolean>>
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'Comfy.Prototype.PartnerNodeVisibility'
|
||||
|
||||
export const usePartnerNodePrototypeStore = defineStore(
|
||||
'partnerNodePrototype',
|
||||
() => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const persisted = useLocalStorage<PersistedPrototypeState>(STORAGE_KEY, {
|
||||
enabledNodeTypesByWorkspace: {}
|
||||
})
|
||||
|
||||
const prototypeWorkspaceId = computed(() => {
|
||||
const workspace = workspaceStore.activeWorkspace
|
||||
if (workspace?.type !== 'team' || workspace.role !== 'owner') return null
|
||||
return workspace.id
|
||||
})
|
||||
|
||||
const enabledNodeTypes = computed(() => {
|
||||
const workspaceId = prototypeWorkspaceId.value
|
||||
if (!workspaceId) return {}
|
||||
return persisted.value.enabledNodeTypesByWorkspace[workspaceId] ?? {}
|
||||
})
|
||||
|
||||
const partnerNodes = computed<PartnerNodePrototypeEntry[]>(() =>
|
||||
Object.values(nodeDefStore.nodeDefsByName)
|
||||
.filter((nodeDef) => nodeDef.api_node)
|
||||
.map((nodeDef) => ({
|
||||
name: nodeDef.name,
|
||||
displayName: nodeDef.display_name,
|
||||
provider: getProviderName(nodeDef.category)
|
||||
}))
|
||||
)
|
||||
|
||||
function isEnabled(nodeType: string): boolean {
|
||||
return enabledNodeTypes.value[nodeType] ?? false
|
||||
}
|
||||
|
||||
const disabledNodeTypes = computed(() => {
|
||||
if (!prototypeWorkspaceId.value) return new Set<string>()
|
||||
return new Set(
|
||||
partnerNodes.value
|
||||
.filter((node) => !isEnabled(node.name))
|
||||
.map((node) => node.name)
|
||||
)
|
||||
})
|
||||
|
||||
function setEnabled(nodeTypes: string[], enabled: boolean) {
|
||||
const workspaceId = prototypeWorkspaceId.value
|
||||
if (!workspaceId) return
|
||||
const nextEnabledNodeTypes = { ...enabledNodeTypes.value }
|
||||
for (const nodeType of nodeTypes) {
|
||||
nextEnabledNodeTypes[nodeType] = enabled
|
||||
}
|
||||
persisted.value = {
|
||||
enabledNodeTypesByWorkspace: {
|
||||
...persisted.value.enabledNodeTypesByWorkspace,
|
||||
[workspaceId]: nextEnabledNodeTypes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
partnerNodes,
|
||||
disabledNodeTypes,
|
||||
isEnabled,
|
||||
setEnabled
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user