Compare commits

...

1 Commits

Author SHA1 Message Date
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 435 additions and 2 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 { installPartnerNodeEnforcement } from '@/platform/workspace/partnerNodeAccess/installPartnerNodeEnforcement'
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()
installPartnerNodeEnforcement()
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",
"Secrets": "Secrets",
"Node Library": "Node Library",
"PointCloud": "Point Cloud"
@@ -2854,6 +2855,16 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"partnerNodes": {
"title": "Partner Nodes",
"description": "Control which partner (API) nodes this workspace can discover and run.",
"searchPlaceholder": "Search partner nodes",
"enableAll": "Enable all",
"disableAll": "Disable all",
"enabledCount": "{enabled} of {total} enabled",
"empty": "No partner nodes are available on this server.",
"autoEnableLabel": "Auto-enable newly added partner nodes"
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
@@ -4393,7 +4404,15 @@
"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",
"hideDisabledPartnerNodes": "Hide Disabled Partner Nodes",
"hideDisabledPartnerNodesDescription": "Hides partner nodes disabled by workspace policy"
},
"partnerNodeAccess": {
"insertBlockedTitle": "Partner node disabled",
"insertBlockedDetail": "{node} is disabled for this workspace. A workspace owner or admin can re-enable it in Settings > Partner Nodes.",
"runBlockedTitle": "Workflow uses disabled partner nodes",
"runBlockedDetail": "This workflow can't run because it uses partner nodes disabled for this workspace: {nodes}. A workspace owner or admin can re-enable them in Settings > Partner Nodes."
},
"secrets": {
"title": "API Keys & Secrets",

View File

@@ -28,6 +28,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]',
@@ -192,6 +193,21 @@ export function useSettingUI(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
// PROTOTYPE (DES-484): unconditionally registered so the demo works on any
// distribution. Production gates on shouldShowWorkspacePanel +
// permissions.canManagePartnerNodes (owner/admin only).
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 +261,7 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
partnerNodesPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
keybindingPanel,
extensionPanel,
@@ -296,6 +313,7 @@ export function useSettingUI(
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
partnerNodesPanel.node,
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
@@ -342,6 +360,7 @@ export function useSettingUI(
label: 'Account',
children: [
userPanel.node,
partnerNodesPanel.node,
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel.node]
: []),

View File

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

View File

@@ -3,6 +3,10 @@ import { computed, ref, shallowRef, watch } from 'vue'
import { i18n, st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import {
normalizeProviderKey,
usePartnerNodeAccessStore
} from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
import { api } from '@/scripts/api'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
@@ -27,6 +31,39 @@ interface EnhancedTemplate extends TemplateInfo {
searchableText?: string
}
/** Normalized provider keys declared by a template's logo overlay metadata. */
function templateProviderKeys(template: EnhancedTemplate): string[] {
return (template.logos ?? [])
.flatMap((logo) =>
Array.isArray(logo.provider) ? logo.provider : [logo.provider]
)
.map(normalizeProviderKey)
}
/**
* PROTOTYPE (DES-484): whether a template must be hidden because of the
* workspace partner-node policy. The template index carries no node-type
* list, so provider identity from logo metadata is the only per-partner
* signal; `isPartnerNode` (openSource === false) marks partner templates
* that may lack that metadata.
*
* @param template - The template under evaluation; `template.isPartnerNode`
* is true when the template runs on partner/API nodes.
* @param providerKeys - Normalized provider keys from the template's logos
* (may be empty when metadata is missing).
* @param disabledPartners - Normalized keys of partners whose nodes are all
* disabled in this workspace.
*/
function isTemplateBlockedByPartnerPolicy(
template: EnhancedTemplate,
providerKeys: string[],
disabledPartners: Set<string>
): boolean {
if (!template.isPartnerNode) return false
if (!providerKeys.length) return true
return providerKeys.some((key) => disabledPartners.has(key))
}
export const useWorkflowTemplatesStore = defineStore(
'workflowTemplates',
() => {
@@ -249,7 +286,16 @@ export const useWorkflowTemplatesStore = defineStore(
(template) => !template.requiresCustomNodes?.length
)
return filteredTemplates
const disabledPartners = usePartnerNodeAccessStore().fullyDisabledPartners
if (!disabledPartners.size) return filteredTemplates
return filteredTemplates.filter(
(template) =>
!isTemplateBlockedByPartnerPolicy(
template,
templateProviderKeys(template),
disabledPartners
)
)
})
/**

View File

@@ -0,0 +1,155 @@
<template>
<TabPanel value="PartnerNodes" class="h-full">
<div class="flex h-full flex-col">
<div>
<h2 class="text-2xl font-bold">
{{ $t('workspacePanel.partnerNodes.title') }}
</h2>
<div class="mt-1 flex items-center justify-between gap-4">
<p class="my-0 text-sm text-muted">
{{ $t('workspacePanel.partnerNodes.description') }}
</p>
<div class="flex shrink-0 gap-2">
<Button variant="textonly" size="sm" @click="setAllFiltered(true)">
{{ $t('workspacePanel.partnerNodes.enableAll') }}
</Button>
<Button variant="textonly" size="sm" @click="setAllFiltered(false)">
{{ $t('workspacePanel.partnerNodes.disableAll') }}
</Button>
</div>
</div>
</div>
<Divider class="my-4" />
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
class="mb-4"
/>
<div
v-if="!accessStore.partnerNodes.length"
class="py-8 text-center text-sm text-muted"
>
{{ $t('workspacePanel.partnerNodes.empty') }}
</div>
<div v-else class="min-h-0 flex-1 overflow-y-auto">
<section
v-for="group in groupedNodes"
:key="group.partner"
class="mb-6"
>
<h3 class="mb-1 text-sm font-semibold">
{{ group.partner }}
<span class="ml-2 font-normal text-muted">
{{
$t('workspacePanel.partnerNodes.enabledCount', {
enabled: group.enabledCount,
total: group.nodes.length
})
}}
</span>
</h3>
<ul class="m-0 list-none p-0">
<li
v-for="node in group.nodes"
:key="node.name"
class="flex items-center justify-between py-1.5"
>
<span
:class="
cn(
'text-sm',
!accessStore.isNodeTypeEnabled(node.name) && 'opacity-40'
)
"
>
{{ node.displayName }}
</span>
<ToggleSwitch
:model-value="accessStore.isNodeTypeEnabled(node.name)"
@update:model-value="
accessStore.setEnabled([node.name], $event)
"
/>
</li>
</ul>
</section>
</div>
<div class="mt-4 flex items-center gap-2 pt-2">
<ToggleSwitch
:model-value="accessStore.autoEnableNew"
@update:model-value="accessStore.setAutoEnableNew($event)"
/>
<span
:class="cn('text-sm', !accessStore.autoEnableNew && 'text-muted')"
>
{{ $t('workspacePanel.partnerNodes.autoEnableLabel') }}
</span>
</div>
</div>
</TabPanel>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Divider from 'primevue/divider'
import TabPanel from 'primevue/tabpanel'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import type { PartnerNodeEntry } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
import { usePartnerNodeAccessStore } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
interface PartnerGroup {
partner: string
nodes: PartnerNodeEntry[]
enabledCount: number
}
const accessStore = usePartnerNodeAccessStore()
const searchQuery = ref('')
const filteredNodes = computed<PartnerNodeEntry[]>(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return accessStore.partnerNodes
return accessStore.partnerNodes.filter(
(node) =>
node.displayName.toLowerCase().includes(query) ||
node.name.toLowerCase().includes(query) ||
node.partner.toLowerCase().includes(query)
)
})
const groupedNodes = computed<PartnerGroup[]>(() => {
const byPartner = new Map<string, PartnerNodeEntry[]>()
for (const node of filteredNodes.value) {
const nodes = byPartner.get(node.partner) ?? []
nodes.push(node)
byPartner.set(node.partner, nodes)
}
return [...byPartner.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([partner, nodes]) => ({
partner,
nodes: nodes.toSorted((a, b) =>
a.displayName.localeCompare(b.displayName)
),
enabledCount: nodes.filter((node) =>
accessStore.isNodeTypeEnabled(node.name)
).length
}))
})
function setAllFiltered(enabled: boolean) {
accessStore.setEnabled(
filteredNodes.value.map((node) => node.name),
enabled
)
}
</script>

View File

@@ -0,0 +1,38 @@
import { watchEffect } from 'vue'
import { t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePartnerNodeAccessStore } from './partnerNodeAccessStore'
/**
* PROTOTYPE (DES-484): hides workspace-disabled partner nodes from every
* discovery surface. The node-def filter covers search (v1/v2) and both
* node-library sidebars via `visibleNodeDefs`; the `skip_list` sync covers
* the litegraph right-click Add Node menu (same pattern as the dev_only
* sync in nodeDefStore). Creation paths that bypass discovery (paste,
* workflow load) are enforced at run time instead.
*/
export function installPartnerNodeEnforcement() {
const accessStore = usePartnerNodeAccessStore()
const nodeDefStore = useNodeDefStore()
nodeDefStore.registerNodeDefFilter({
id: 'core.partnerNodeAccess',
name: t('nodeFilters.hideDisabledPartnerNodes'),
description: t('nodeFilters.hideDisabledPartnerNodesDescription'),
predicate: (nodeDef) => !accessStore.disabledNodeTypes.has(nodeDef.name)
})
watchEffect(() => {
const disabled = accessStore.disabledNodeTypes
for (const [typeName, nodeType] of Object.entries(
LiteGraph.registered_node_types
)) {
if (nodeType.nodeData?.api_node) {
nodeType.skip_list = disabled.has(typeName)
}
}
})
}

View File

@@ -0,0 +1,106 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getProviderName } from '@/utils/categoryUtil'
export interface PartnerNodeEntry {
name: string
displayName: string
partner: string
}
interface PersistedAccessState {
overrides: Record<string, boolean>
autoEnableNew: boolean
}
/**
* PROTOTYPE (DES-484): persisted per-browser via localStorage. The
* production version reads/writes `/api/workspace/partner-nodes` and is
* enforced server-side at `/prompt`; this store's public shape mirrors
* that contract so consumers survive the swap.
*/
const STORAGE_KEY = 'Comfy.Prototype.PartnerNodeAccess'
export function normalizeProviderKey(provider: string): string {
return provider.toLowerCase().replaceAll(/\s+/g, '-')
}
export const usePartnerNodeAccessStore = defineStore(
'partnerNodeAccess',
() => {
const nodeDefStore = useNodeDefStore()
const persisted = useLocalStorage<PersistedAccessState>(STORAGE_KEY, {
overrides: {},
autoEnableNew: true
})
const partnerNodes = computed<PartnerNodeEntry[]>(() =>
Object.values(nodeDefStore.nodeDefsByName)
.filter((def) => def.api_node)
.map((def) => ({
name: def.name,
displayName: def.display_name,
partner: getProviderName(def.category)
}))
)
const autoEnableNew = computed(() => persisted.value.autoEnableNew)
function isNodeTypeEnabled(name: string): boolean {
return persisted.value.overrides[name] ?? persisted.value.autoEnableNew
}
const disabledNodeTypes = computed<Set<string>>(
() =>
new Set(
partnerNodes.value
.filter((node) => !isNodeTypeEnabled(node.name))
.map((node) => node.name)
)
)
/** Partners whose nodes are all disabled, as normalized provider keys. */
const fullyDisabledPartners = computed<Set<string>>(() => {
const partnerHasEnabledNode = new Map<string, boolean>()
for (const node of partnerNodes.value) {
const key = normalizeProviderKey(node.partner)
const anyEnabled = partnerHasEnabledNode.get(key) ?? false
partnerHasEnabledNode.set(
key,
anyEnabled || isNodeTypeEnabled(node.name)
)
}
return new Set(
[...partnerHasEnabledNode]
.filter(([, anyEnabled]) => !anyEnabled)
.map(([key]) => key)
)
})
function setEnabled(names: string[], enabled: boolean) {
const overrides = { ...persisted.value.overrides }
for (const name of names) {
overrides[name] = enabled
}
persisted.value = { ...persisted.value, overrides }
}
function setAutoEnableNew(enabled: boolean) {
persisted.value = { ...persisted.value, autoEnableNew: enabled }
}
return {
partnerNodes,
autoEnableNew,
disabledNodeTypes,
fullyDisabledPartners,
isNodeTypeEnabled,
setEnabled,
setAutoEnableNew
}
}
)

View File

@@ -31,6 +31,7 @@ import { installNodeAddedTelemetry } from '@/platform/telemetry/nodeAdded/instal
import { groupMissingNodesByPack } from '@/platform/telemetry/utils/groupMissingNodesByPack'
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { usePartnerNodeAccessStore } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -1610,11 +1611,43 @@ export class ComfyApp {
})
}
/**
* PROTOTYPE (DES-484): display names of workspace-disabled partner nodes
* present in the current graph (including subgraphs). Production replaces
* this client check with server-side `/prompt` rejection.
*/
collectDisabledPartnerNodes(): string[] {
const disabled = usePartnerNodeAccessStore().disabledNodeTypes
if (!disabled.size) return []
const blocked = new Set<string>()
forEachNode(this.rootGraph, (node) => {
const nodeData = node.constructor?.nodeData
const typeName = nodeData?.name ?? node.type
if (typeName && disabled.has(typeName)) {
blocked.add(String(nodeData?.display_name ?? typeName))
}
})
return [...blocked]
}
async queuePrompt(
number: number,
batchCount: number = 1,
queueNodeIds?: NodeExecutionId[]
): Promise<boolean> {
const disabledPartnerNodes = this.collectDisabledPartnerNodes()
if (disabledPartnerNodes.length) {
useDialogService().showErrorDialog(
new Error(
t('partnerNodeAccess.runBlockedDetail', {
nodes: disabledPartnerNodes.join(', ')
})
),
{ title: t('partnerNodeAccess.runBlockedTitle') }
)
return false
}
const requestId = this.nextQueueRequestId++
this.queueItems.push({ number, batchCount, queueNodeIds, requestId })
api.dispatchCustomEvent('promptQueueing', {

View File

@@ -38,6 +38,7 @@ import type {
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { usePartnerNodeAccessStore } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { createPromotedMultilineWidget } from '@/renderer/extensions/vueNodes/widgets/utils/multilineTextarea'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -894,6 +895,18 @@ export const useLitegraphService = () => {
options: CreateNodeOptions = {},
addOptions?: GraphAddOptions
): LGraphNode | null {
if (usePartnerNodeAccessStore().disabledNodeTypes.has(nodeDef.name)) {
useToastStore().add({
severity: 'warn',
summary: t('partnerNodeAccess.insertBlockedTitle'),
detail: t('partnerNodeAccess.insertBlockedDetail', {
node: nodeDef.display_name ?? nodeDef.name
}),
life: 5000
})
return null
}
options.pos ??= getCanvasCenter()
if (isBlueprintType(nodeDef.name)) {