Compare commits

...

2 Commits

Author SHA1 Message Date
huang47
dd24e83ef1 feat(workspace): prototype partner-node discovery controls 2026-07-10 15:24:50 -07:00
huang47
08461c6c49 feat(workspace): prototype partner-node toggle with client-side enforcement
Workspace-level enable/disable for partner (API) nodes (DES-484).
Disabled nodes are hidden from search/library/context menu, blocked
from insertion, filtered out of the template gallery, and block
workflow execution with an explanatory dialog. State is
localStorage-backed pending the workspace backend endpoints.
2026-07-10 12:37:28 -07:00
10 changed files with 602 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,3 +87,4 @@ export type SettingPanelType =
| 'subscription'
| 'user'
| 'workspace'
| 'workspace-partner-nodes'

View File

@@ -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')
})
})

View File

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

View File

@@ -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'
])
})
})

View File

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

View File

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