Add custom nodes manager UI (#2923)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-03-08 16:54:08 -07:00
committed by GitHub
parent f53c04834f
commit d8721760f1
42 changed files with 1792 additions and 19 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -0,0 +1,31 @@
<template>
<hr
:class="{
'm-0': true,
'border-t': orientation === 'horizontal',
'border-l': orientation === 'vertical',
'h-full': orientation === 'vertical',
'w-full': orientation === 'horizontal'
}"
:style="{
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
borderWidth: `${width}px !important`
}"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const colorPaletteStore = useColorPaletteStore()
const { orientation = 'horizontal', width = 0.3 } = defineProps<{
orientation?: 'horizontal' | 'vertical'
width?: number
}>()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
</script>

View File

@@ -0,0 +1,202 @@
<template>
<div
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
:aria-label="$t('manager.title')"
>
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
text
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
<ManagerNavSidebar
v-if="isSideNavOpen"
:tabs="tabs"
:selected-tab="selectedTab"
@update:selected-tab="handleTabSelection"
/>
<div
class="flex-1 overflow-auto"
:class="{
'transition-all duration-300': isSmallScreen,
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen,
'pr-80': showInfoPanel
}"
>
<div class="px-6 pt-6 flex flex-col h-full">
<RegistrySearchBar
v-if="!hideSearchBar"
v-model:searchQuery="searchQuery"
:searchResults="searchResults"
@update:sortBy="handleSortChange"
@update:filterBy="handleFilterChange"
/>
<div class="flex-1 overflow-auto">
<NoResultsPlaceholder
v-if="error || searchResults.length === 0"
:title="
error
? $t('manager.errorConnecting')
: $t('manager.noResultsFound')
"
:message="
error
? $t('manager.tryAgainLater')
: $t('manager.tryDifferentSearch')
"
/>
<div
v-else-if="isLoading"
class="flex justify-center items-center h-full"
>
<ProgressSpinner />
</div>
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
:items="resultsWithKeys"
:defaultItemSize="DEFAULT_CARD_SIZE"
class="p-0 m-0 max-w-full"
:buffer-rows="2"
:gridStyle="{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${DEFAULT_CARD_SIZE}px, 1fr))`,
padding: '0.5rem',
gap: '1.125rem 1.25rem',
justifyContent: 'stretch'
}"
>
<template #item="{ item }">
<div
class="relative w-full aspect-square cursor-pointer"
@click.stop="(event) => selectNodePack(item, event)"
>
<PackCard
:node-pack="item"
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
/>
</div>
</template>
</VirtualGrid>
</div>
</div>
</div>
</div>
<div
v-if="showInfoPanel"
class="w-80 border-l-0 border-surface-border absolute right-0 top-0 bottom-0 flex z-20"
>
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ManagerNavSidebar from '@/components/dialog/content/manager/ManagerNavSidebar.vue'
import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.vue'
import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import type { NodeField, TabItem } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_CARD_SIZE = 512
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const hideSearchBar = computed(() => isSmallScreen.value && showInfoPanel.value)
const tabs = ref<TabItem[]>([
{ id: 'all', label: 'All', icon: 'pi-list' },
{ id: 'community', label: 'Community', icon: 'pi-globe' },
{ id: 'installed', label: 'Installed', icon: 'pi-box' }
])
const selectedTab = ref<TabItem>(tabs.value[0])
const handleTabSelection = (tab: TabItem) => {
selectedTab.value = tab
}
const { searchQuery, pageNumber, sortField, isLoading, error, searchResults } =
useRegistrySearch()
pageNumber.value = 1
const resultsWithKeys = computed(() =>
searchResults.value.map((item) => ({
...item,
key: item.id || item.name
}))
)
const selectedNodePacks = ref<components['schemas']['Node'][]>([])
const selectedNodePack = computed(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
) => {
// Handle multi-select with Shift or Ctrl/Cmd key
if (event.shiftKey || event.ctrlKey || event.metaKey) {
const index = selectedNodePacks.value.findIndex(
(pack) => pack.id === nodePack.id
)
if (index === -1) {
// Add to selection if not already selected
selectedNodePacks.value.push(nodePack)
} else {
// Remove from selection if already selected
selectedNodePacks.value.splice(index, 1)
}
} else {
// Single select behavior
selectedNodePacks.value = [nodePack]
}
}
const unSelectItems = () => {
selectedNodePacks.value = []
}
const handleGridContainerClick = (event: MouseEvent) => {
const targetElement = event.target as HTMLElement
if (targetElement && !targetElement.closest('[data-virtual-grid-item]')) {
unSelectItems()
}
}
const showInfoPanel = computed(() => selectedNodePacks.value.length > 0)
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
const currentFilterBy = ref('all')
const handleSortChange = (sortBy: NodeField) => {
sortField.value = sortBy
}
const handleFilterChange = (filterBy: NodeField) => {
currentFilterBy.value = filterBy
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="w-full">
<div class="px-6 py-4">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
<ContentDivider :width="0.3" />
</div>
</template>
<script setup lang="ts">
import ContentDivider from '@/components/common/ContentDivider.vue'
</script>

View File

@@ -0,0 +1,50 @@
<template>
<aside
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
>
<ScrollPanel class="w-80 mt-7">
<Listbox
:model-value="selectedTab"
:options="tabs"
optionLabel="label"
listStyle="max-height:unset"
:pt="{
root: { class: 'w-full border-0 bg-transparent' },
list: { class: 'p-5' },
option: { class: 'px-8 py-3 text-lg rounded-xl' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
@update:model-value="handleTabSelection"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'mr-3']"></i>
<span class="text-lg">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" />
</aside>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import ContentDivider from '@/components/common/ContentDivider.vue'
import type { TabItem } from '@/types/comfyManagerTypes'
defineProps<{
tabs: TabItem[]
selectedTab: TabItem
}>()
const emit = defineEmits<{
'update:selectedTab': [value: TabItem]
}>()
const handleTabSelection = (tab: TabItem) => {
emit('update:selectedTab', tab)
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<Button
outlined
class="m-0 p-0 rounded-lg border-neutral-700"
severity="secondary"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
>
<span class="py-2.5 px-3">
{{ multi ? $t('manager.installSelected') : $t('g.install') }}
</span>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
defineProps<{
fullWidth?: boolean
multi?: boolean
}>()
</script>

View File

@@ -0,0 +1,82 @@
<template>
<Message
:severity="statusSeverity"
class="p-0 flex items-center rounded-xl break-words w-fit"
:pt="{
text: { class: 'text-xs' },
content: { class: 'px-2 py-0.5' }
}"
>
<i
class="pi pi-circle-fill mr-1.5 text-[0.6rem] p-0"
:style="{ opacity: 0.8 }"
></i>
{{ $t(`manager.status.${statusLabel}`) }}
</Message>
</template>
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { components } from '@/types/comfyRegistryTypes'
import { VueSeverity } from '@/types/primeVueTypes'
const { t } = useI18n()
type PackVersionStatus = components['schemas']['NodeVersionStatus']
type PackStatus = components['schemas']['NodeStatus']
type Status = PackVersionStatus | PackStatus
type StatusProps = {
label: string
severity: VueSeverity
}
const { statusType } = defineProps<{
statusType: Status
}>()
const statusPropsMap: Record<Status, StatusProps> = {
NodeStatusActive: {
label: 'active',
severity: 'success'
},
NodeStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeStatusBanned: {
label: 'banned',
severity: 'danger'
},
NodeVersionStatusActive: {
label: 'active',
severity: 'success'
},
NodeVersionStatusPending: {
label: 'pending',
severity: 'warn'
},
NodeVersionStatusDeleted: {
label: 'deleted',
severity: 'warn'
},
NodeVersionStatusFlagged: {
label: 'flagged',
severity: 'danger'
},
NodeVersionStatusBanned: {
label: 'banned',
severity: 'danger'
}
}
const statusLabel = computed(
() => statusPropsMap[statusType]?.label || 'unknown'
)
const statusSeverity = computed(
() => statusPropsMap[statusType]?.severity || 'secondary'
)
</script>

View File

@@ -0,0 +1,23 @@
<template>
<Button
v-if="version"
:label="version"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
class="rounded-xl text-xs tracking-tighter"
:pt="{
root: { class: 'p-0' },
label: { class: 'pl-2 pr-0 py-0.5' },
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
}"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
defineProps<{
version: string | undefined
}>()
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="flex flex-col h-full z-40 hidden-scrollbar w-80">
<div class="p-6 flex-1 overflow-hidden text-sm">
<PackCardHeader
:node-pack="nodePack"
:install-button-full-width="false"
/>
<div class="mb-6">
<MetadataRow
v-for="item in infoItems"
v-show="item.value !== undefined && item.value !== null"
:key="item.key"
:label="item.label"
:value="item.value"
/>
<MetadataRow :label="t('g.status')">
<PackStatusMessage
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge :version="nodePack.latest_version?.version" />
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs :node-pack="nodePack" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
interface InfoItem {
key: string
label: string
value: string | number | undefined
}
const { t } = useI18n()
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
label: `${t('manager.createdBy')}`,
value: nodePack.publisher?.name
},
{
key: 'downloads',
label: t('manager.downloads'),
value: nodePack.downloads ? formatNumber(nodePack.downloads) : undefined
},
{
key: 'lastUpdated',
label: t('manager.lastUpdated'),
value: nodePack.latest_version?.createdAt
? new Date(nodePack.latest_version.createdAt).toLocaleDateString()
: undefined
}
])
</script>
<style scoped>
.hidden-scrollbar {
height: 100%;
overflow-y: auto;
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<PackCardHeader>
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
{{ nodePacks.length }}
{{ $t('manager.packsSelected') }}
</template>
<template #install-button>
<PackInstallButton :full-width="true" :multi="true" />
</template>
</PackCardHeader>
<div class="mb-6">
<MetadataRow :label="$t('g.status')">
<PackStatusMessage status-type="NodeVersionStatusActive" />
</MetadataRow>
<MetadataRow
:label="$t('manager.totalNodes')"
:value="totalNodesCount"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed } from 'vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import PackCardHeader from '@/components/dialog/content/manager/packCard/PackCardHeader.vue'
import PackIconStacked from '@/components/dialog/content/manager/packIcon/PackIconStacked.vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { components } from '@/types/comfyRegistryTypes'
const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const comfyRegistryService = useComfyRegistryService()
const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!comfyRegistryService.packNodesAvailable(pack)) return []
return comfyRegistryService.getNodeDefs({
packId: pack.id,
versionId: pack.latest_version.id
})
}
const { state: allNodeDefs } = useAsyncState(
() => Promise.all(nodePacks.map(getPackNodes)),
[],
{
immediate: true
}
)
const totalNodesCount = computed(() =>
allNodeDefs.value.reduce(
(total, nodeDefs) => total + (nodeDefs?.length || 0),
0
)
)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList>
<Tab value="description">{{ $t('g.description') }}</Tab>
<Tab value="nodes">{{ $t('g.nodes') }}</Tab>
</TabList>
<TabPanels class="overflow-auto">
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
<TabPanel value="nodes">
<NodesTabPanel :node-pack="nodePack" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { ref } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack: components['schemas']['Node']
}>()
const activeTab = ref('description')
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-1">{{ section.title }}</div>
<div class="text-muted break-words">
<a
v-if="section.isUrl"
:href="section.text"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 hover:underline"
>
<i
v-if="isGitHubLink(section.text)"
class="pi pi-github text-base"
></i>
<span class="break-all">{{ section.text }}</span>
</a>
<MarkdownText v-else :text="section.text" class="text-muted" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MarkdownText from '@/components/dialog/content/manager/infoPanel/MarkdownText.vue'
export interface TextSection {
title: string
text: string
isUrl?: boolean
}
defineProps<{
sections: TextSection[]
}>()
const isGitHubLink = (url: string): boolean => url.includes('github.com')
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="hover:underline">
<div v-if="!hasMarkdown" v-text="text" class="break-words"></div>
<div v-else class="break-words">
<template v-for="(segment, index) in parsedSegments" :key="index">
<a
v-if="segment.type === 'link' && 'url' in segment"
:href="segment.url"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
>
<span class="text-blue-600">{{ segment.text }}</span>
</a>
<strong v-else-if="segment.type === 'bold'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'italic'">{{ segment.text }}</em>
<code
v-else-if="segment.type === 'code'"
class="bg-surface-100 px-1 py-0.5 rounded text-xs"
>{{ segment.text }}</code
>
<span v-else>{{ segment.text }}</span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { text } = defineProps<{
text: string
}>()
type MarkdownSegment = {
type: 'text' | 'link' | 'bold' | 'italic' | 'code'
text: string
url?: string
}
const hasMarkdown = computed(() => {
const hasMarkdown =
/(\[.*?\]\(.*?\)|(\*\*|__)(.*?)(\*\*|__)|(\*|_)(.*?)(\*|_)|`(.*?)`)/.test(
text
)
return hasMarkdown
})
const parsedSegments = computed(() => {
if (!hasMarkdown.value) return [{ type: 'text', text }]
const segments: MarkdownSegment[] = []
const remainingText = text
let lastIndex: number = 0
const linkRegex = /\[(.*?)\]\((.*?)\)/g
let linkMatch: RegExpExecArray | null
while ((linkMatch = linkRegex.exec(remainingText)) !== null) {
// Add text before the match
if (linkMatch.index > lastIndex) {
segments.push({
type: 'text',
text: remainingText.substring(lastIndex, linkMatch.index)
})
}
// Add the link
segments.push({
type: 'link',
text: linkMatch[1],
url: linkMatch[2]
})
lastIndex = linkMatch.index + linkMatch[0].length
}
// Add remaining text after all links
if (lastIndex < remainingText.length) {
let rest = remainingText.substring(lastIndex)
// Process bold text
rest = rest.replace(/(\*\*|__)(.*?)(\*\*|__)/g, (_, __, p2) => {
segments.push({ type: 'bold', text: p2 })
return ''
})
// Process italic text
rest = rest.replace(/(\*|_)(.*?)(\*|_)/g, (_, __, p2) => {
segments.push({ type: 'italic', text: p2 })
return ''
})
// Process code
rest = rest.replace(/`(.*?)`/g, (_, p1) => {
segments.push({ type: 'code', text: p1 })
return ''
})
// Add any remaining text
if (rest) {
segments.push({ type: 'text', text: rest })
}
}
return segments
})
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex py-1.5 text-xs">
<div class="w-1/3 text-color-secondary truncate pr-2 text-muted">
{{ label }}:
</div>
<div class="w-2/3">
<slot>{{ value }}</slot>
</div>
</div>
</template>
<script setup lang="ts">
const { value = 'N/A' } = defineProps<{
label: string
value?: string | number
}>()
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="mt-4 overflow-hidden">
<InfoTextSection
v-if="nodePack.description"
:sections="descriptionSections"
/>
<p v-else class="text-muted italic text-sm">
{{ $t('manager.noDescription') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import InfoTextSection, {
type TextSection
} from '@/components/dialog/content/manager/infoPanel/InfoTextSection.vue'
import { components } from '@/types/comfyRegistryTypes'
const { t } = useI18n()
const props = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isValidUrl = (url: string): boolean => {
try {
new URL(url)
return true
} catch {
return false
}
}
const isLicenseFile = (filename: string): boolean => {
// Match LICENSE, LICENSE.md, LICENSE.txt (case insensitive)
const licensePattern = /^license(\.md|\.txt)?$/i
return licensePattern.test(filename)
}
const extractBaseRepoUrl = (repoUrl: string): string => {
// Match GitHub repository URL and extract the base URL
const githubRepoPattern = /^(https?:\/\/github\.com\/[^/]+\/[^/]+)/i
const match = repoUrl.match(githubRepoPattern)
return match ? match[1] : repoUrl
}
const createLicenseUrl = (filename: string, repoUrl: string): string => {
if (!repoUrl || !filename) return ''
// Use the filename if it's a license file, otherwise use LICENSE
const licenseFile = isLicenseFile(filename) ? filename : 'LICENSE'
// Get the base repository URL
const baseRepoUrl = extractBaseRepoUrl(repoUrl)
return `${baseRepoUrl}/blob/main/${licenseFile}`
}
const parseLicenseObject = (
licenseObj: any
): { text: string; isUrl: boolean } => {
// Get the license file or text
const licenseFile = licenseObj.file || licenseObj.text
// If it's a string and a license file, create a URL
if (typeof licenseFile === 'string' && isLicenseFile(licenseFile)) {
const url = createLicenseUrl(licenseFile, props.nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
}
// Otherwise use the text directly
else if (licenseObj.text) {
return {
text: licenseObj.text,
isUrl: false
}
}
// Default fallback
return {
text: JSON.stringify(licenseObj),
isUrl: false
}
}
const formatLicense = (license: string): { text: string; isUrl: boolean } => {
try {
const licenseObj = JSON.parse(license)
return parseLicenseObject(licenseObj)
} catch (e) {
// Not JSON, handle as plain string
// If it's a license file, create a URL
if (isLicenseFile(license)) {
const url = createLicenseUrl(license, props.nodePack.repository)
return {
text: url,
isUrl: !!url && isValidUrl(url)
}
}
// Otherwise return as is
return {
text: license,
isUrl: false
}
}
}
const descriptionSections = computed<TextSection[]>(() => {
const sections: TextSection[] = [
{
title: t('g.description'),
text: props.nodePack.description || t('manager.noDescription')
}
]
if (props.nodePack.repository) {
sections.push({
title: t('manager.repository'),
text: props.nodePack.repository,
isUrl: isValidUrl(props.nodePack.repository)
})
}
if (props.nodePack.license) {
const { text, isUrl } = formatLicense(props.nodePack.license)
if (text) {
sections.push({
title: t('manager.license'),
text,
isUrl
})
}
}
return sections
})
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-4 mt-4 overflow-auto text-sm">
<div v-if="nodeDefs?.length">
<!-- TODO: when registry returns node defs, use them here -->
</div>
<div
v-else
v-for="i in 3"
:key="i"
class="border border-surface-border rounded-lg p-4"
>
<NodePreview :node-def="placeholderNodeDef" />
</div>
</div>
</template>
<script setup lang="ts">
import NodePreview from '@/components/node/NodePreview.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack: components['schemas']['Node']
nodeDefs?: components['schemas']['ComfyNode'][]
}>()
// TODO: when registry returns node defs, use them here
const placeholderNodeDef = {
display_name: 'Sample Node',
description: 'This is a sample node for preview purposes',
inputs: {
input1: { name: 'Input 1', type: 'IMAGE' },
input2: { name: 'Input 2', type: 'CONDITIONING' }
},
outputs: [
{ name: 'Output 1', type: 'IMAGE', index: 0 },
{ name: 'Output 2', type: 'MASK', index: 1 }
]
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<Card
class="absolute inset-0 flex flex-col overflow-hidden rounded-2xl shadow-[0_0_15px_rgba(0,0,0,0.15),0_10px_15px_-3px_rgba(0,0,0,0.12),0_4px_6px_-4px_rgba(0,0,0,0.08)] transition-all duration-200"
:class="{
'bg-[#ffffff08]': !isLightTheme,
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected
}"
:pt="{
body: { class: 'p-0 flex flex-col h-full rounded-2xl' },
content: { class: 'flex-1 flex flex-col rounded-2xl' },
title: { class: 'p-0 m-0' },
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<div class="flex justify-between p-5 pb-1 align-middle text-sm">
<span class="flex items-start mt-2">
<i
class="pi pi-box text-muted text-2xl ml-1 mr-5"
style="opacity: 0.5"
></i>
<span class="text-lg relative top-[.25rem]">{{
$t('manager.nodePack')
}}</span>
</span>
<div class="flex items-center gap-2.5">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2"></i>
{{ formatNumber(nodePack.downloads) }}
</div>
<PackInstallButton />
</div>
</div>
</template>
<template #content>
<ContentDivider />
<div class="flex flex-1 p-5 mt-3 cursor-pointer">
<div class="flex-shrink-0 mr-4">
<PackIcon :node-pack="nodePack" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<span
class="text-lg font-bold pb-4 truncate overflow-hidden text-ellipsis"
:title="nodePack.name"
>
{{ nodePack.name }}
</span>
<div class="flex-1">
<p
v-if="nodePack.description"
class="text-sm text-color-secondary m-0 line-clamp-3 overflow-hidden"
:title="nodePack.description"
>
{{ nodePack.description }}
</p>
</div>
</div>
</div>
</template>
<template #footer>
<ContentDivider :width="0.1" />
<div class="flex justify-between p-5 text-xs text-muted">
<div class="flex items-center gap-2 cursor-pointer">
<span v-if="nodePack.publisher?.name">
{{ nodePack.publisher.name }}
</span>
<span v-if="nodePack.latest_version">
{{ nodePack.latest_version.version }}
</span>
</div>
<div v-if="nodePack.latest_version" class="flex items-center gap-2">
{{ $t('g.updated') }}
{{ new Date(nodePack.latest_version.createdAt).toLocaleDateString() }}
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import { computed } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import type { components } from '@/types/comfyRegistryTypes'
import { formatNumber } from '@/utils/formatUtil'
defineProps<{
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex flex-col items-center mb-6">
<slot name="thumbnail">
<PackIcon :node-pack="nodePack" width="24" height="24" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">{{ nodePack?.name }}</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
<slot name="install-button">
<PackInstallButton
:full-width="installButtonFullWidth"
:multi="multi"
/>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import PackInstallButton from '@/components/dialog/content/manager/PackInstallButton.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
nodePack?: components['schemas']['Node']
multi?: boolean
installButtonFullWidth?: boolean
}>()
</script>

View File

@@ -0,0 +1,41 @@
<template>
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '4.5rem',
height = '4.5rem'
} = defineProps<{
nodePack: components['schemas']['Node']
width?: string
height?: string
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.icon || nodePack.icon.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_ICON : nodePack.icon
)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div class="relative w-24 h-24">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div
class="border border-surface-border bg-surface-card rounded-lg p-0.5"
>
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
</div>
</div>
<div
v-if="nodePacks.length > maxVisible"
class="absolute -top-2 -right-2 bg-primary rounded-full w-7 h-7 flex items-center justify-center text-xs font-bold shadow-md z-10"
>
+{{ nodePacks.length - maxVisible }}
</div>
</div>
</template>
<script setup lang="ts">
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { components } from '@/types/comfyRegistryTypes'
const {
nodePacks,
maxVisible = 3,
offset = 8
} = defineProps<{
nodePacks: components['schemas']['Node'][]
maxVisible?: number
offset?: number
}>()
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div class="relative w-full p-6">
<div class="flex items-center w-full">
<IconField class="w-5/12">
<InputIcon class="pi pi-search" />
<InputText
:model-value="searchQuery"
@update:model-value="$emit('update:searchQuery', $event)"
:placeholder="$t('manager.searchPlaceholder')"
class="w-full rounded-2xl"
autofocus
/>
</IconField>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model="currentFilter"
:options="filterOptions"
:label="$t('g.filter')"
@update:model-value="handleFilterChange"
/>
<SearchFilterDropdown
v-model="currentSort"
:options="sortOptions"
:label="$t('g.sort')"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center gap-4 ml-6">
<small v-if="hasResults" class="text-color-secondary">
{{ $t('g.resultsCount', { count: searchResults.length }) }}
</small>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import type { NodeField, SearchOption } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_SORT: NodeField = 'downloads'
const DEFAULT_FILTER = 'nodePack'
const props = defineProps<{
searchQuery: string
searchResults?: components['schemas']['Node'][]
}>()
const { t } = useI18n()
const currentSort = ref<NodeField>(DEFAULT_SORT)
const currentFilter = ref<string>(DEFAULT_FILTER)
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:sortBy': [value: NodeField]
'update:filterBy': [value: string]
}>()
const hasResults = computed(
() => props.searchQuery.trim() && props.searchResults?.length
)
const sortOptions: SearchOption<NodeField>[] = [
{ id: 'downloads', label: t('manager.sort.downloads') },
{ id: 'name', label: t('g.name') },
{ id: 'rating', label: t('manager.sort.rating') },
{ id: 'category', label: t('g.category') }
]
const filterOptions: SearchOption<string>[] = [
{ id: 'nodePack', label: t('manager.filter.nodePack') },
{ id: 'node', label: t('g.nodes') }
]
const handleSortChange = () => {
// TODO: emit to Algolia service
emit('update:sortBy', currentSort.value)
}
const handleFilterChange = () => {
// TODO: emit to Algolia service
emit('update:filterBy', currentFilter.value)
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div class="flex items-center gap-1">
<span class="text-muted">{{ label }}:</span>
<Dropdown
v-model="selectedValue"
:options="options"
optionLabel="label"
optionValue="id"
class="min-w-[6rem]"
@change="handleChange"
:pt="{
root: { class: 'border-none' },
input: { class: 'py-0 px-1 border-none' },
trigger: { class: 'hidden' },
panel: { class: 'shadow-md' },
item: { class: 'py-2 px-3 text-sm' }
}"
/>
</div>
</template>
<script setup lang="ts" generic="T">
import Dropdown from 'primevue/dropdown'
import { computed } from 'vue'
import type { SearchOption } from '@/types/comfyManagerTypes'
const { modelValue, options, label } = defineProps<{
modelValue: T
options: SearchOption<T>[]
label: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: T]
}>()
const selectedValue = computed({
get: () => modelValue,
set: (value) => emit('update:modelValue', value)
})
const handleChange = () => {
emit('update:modelValue', selectedValue.value)
}
</script>

View File

@@ -581,6 +581,15 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.deleteSelected()
app.canvas.setDirty(true, true)
}
},
{
id: 'Comfy.Manager.CustomNodesManager',
icon: 'pi pi-puzzle',
label: 'Custom Nodes Manager',
versionAdded: '1.12.10',
function: () => {
dialogService.showManagerDialog()
}
}
]
}

View File

@@ -7,6 +7,7 @@ import type { components } from '@/types/comfyRegistryTypes'
const SEARCH_DEBOUNCE_TIME = 256
const DEFAULT_PAGE_SIZE = 60
const DEFAULT_SORT_FIELD: keyof components['schemas']['Node'] = 'downloads'
/**
* Composable for managing UI state of Comfy Node Registry search.
@@ -18,6 +19,7 @@ export function useRegistrySearch() {
const searchQuery = ref('')
const pageNumber = ref(1)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const sortField = ref<keyof components['schemas']['Node']>(DEFAULT_SORT_FIELD)
const searchResults = ref<components['schemas']['Node'][]>([])
const search = async () => {
@@ -26,7 +28,8 @@ export function useRegistrySearch() {
const result = isEmptySearch
? await registryStore.listAllPacks({
page: pageNumber.value,
limit: pageSize.value
limit: pageSize.value,
sort: [sortField.value]
})
: await registryService.search({
search: searchQuery.value,
@@ -45,12 +48,12 @@ export function useRegistrySearch() {
}
}
// Debounce search when query changes
const debouncedSearch = debounce(search, SEARCH_DEBOUNCE_TIME)
// Debounce search when query changes
watch(() => searchQuery.value, debouncedSearch)
// Normal search when page number changes and on load
watch(() => pageNumber.value, search, { immediate: true })
watch(() => [pageNumber.value, sortField.value], search, { immediate: true })
onUnmounted(() => {
debouncedSearch.cancel() // Cancel debounced searches
@@ -61,6 +64,7 @@ export function useRegistrySearch() {
return {
pageNumber,
pageSize,
sortField,
searchQuery,
searchResults,
isLoading: registryService.isLoading,

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Load Default Workflow"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Custom Nodes Manager"
},
"Comfy_NewBlankWorkflow": {
"label": "New Blank Workflow"
},

View File

@@ -78,7 +78,55 @@
"control_after_generate": "control after generate",
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"capture": "capture"
"capture": "capture",
"nodes": "Nodes",
"community": "Community",
"all": "All",
"updated": "Updated",
"resultsCount": "Found {count} Results",
"status": "Status",
"description": "Description",
"name": "Name",
"category": "Category",
"sort": "Sort",
"filter": "Filter"
},
"manager": {
"title": "Custom Nodes Manager",
"downloads": "Downloads",
"repository": "Repository",
"license": "License",
"createdBy": "Created By",
"totalNodes": "Total Nodes",
"discoverCommunityContent": "Discover community-made Node Packs, Extensions, and more...",
"errorConnecting": "Error connecting to the Comfy Node Registry.",
"noResultsFound": "No results found matching your search.",
"tryDifferentSearch": "Please try a different search query.",
"tryAgainLater": "Please try again later.",
"nodePack": "Node Pack",
"searchPlaceholder": "Search",
"version": "Version",
"lastUpdated": "Last Updated",
"noDescription": "No description available",
"installSelected": "Install Selected",
"packsSelected": "Packs Selected",
"status": {
"active": "Active",
"pending": "Pending",
"flagged": "Flagged",
"deleted": "Deleted",
"banned": "Banned",
"unknown": "Unknown"
},
"sort": {
"rating": "Rating",
"downloads": "Most Popular"
},
"filter": {
"nodePack": "Node Pack",
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"issueReport": {
"submitErrorReport": "Submit Error Report (Optional)",
@@ -512,6 +560,7 @@
"ComfyUI Issues": "ComfyUI Issues",
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Custom Nodes Manager": "Custom Nodes Manager",
"New": "New",
"Clipspace": "Clipspace",
"Open": "Open",

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Charger le flux de travail par défaut"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Gestionnaire de Nœuds Personnalisés"
},
"Comfy_NewBlankWorkflow": {
"label": "Nouveau flux de travail vierge"
},

View File

@@ -116,14 +116,17 @@
"g": {
"about": "À propos",
"add": "Ajouter",
"all": "Tout",
"back": "Retour",
"cancel": "Annuler",
"capture": "capture",
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"close": "Fermer",
"color": "Couleur",
"comingSoon": "Bientôt disponible",
"command": "Commande",
"community": "Communauté",
"confirm": "Confirmer",
"continue": "Continuer",
"control_after_generate": "contrôle après génération",
@@ -134,6 +137,7 @@
"customizeFolder": "Personnaliser le dossier",
"delete": "Supprimer",
"deprecated": "DEPR",
"description": "Description",
"devices": "Appareils",
"disableAll": "Désactiver tout",
"download": "Télécharger",
@@ -144,6 +148,7 @@
"export": "Exportation",
"extensionName": "Nom de l'extension",
"feedback": "Commentaires",
"filter": "Filtrer",
"findIssues": "Trouver des problèmes",
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
"goToNode": "Aller au nœud",
@@ -157,6 +162,7 @@
"loadWorkflow": "Charger le flux de travail",
"loading": "Chargement",
"logs": "Journaux",
"name": "Nom",
"newFolder": "Nouveau dossier",
"next": "Suivant",
"no": "Non",
@@ -164,6 +170,7 @@
"noTasksFound": "Aucune tâche trouvée",
"noTasksFoundMessage": "Il n'y a pas de tâches dans la file d'attente.",
"noWorkflowsFound": "Aucun flux de travail trouvé.",
"nodes": "Nœuds",
"ok": "OK",
"openNewIssue": "Ouvrir un nouveau problème",
"overwrite": "Écraser",
@@ -177,6 +184,7 @@
"reportSent": "Rapport soumis",
"reset": "Réinitialiser",
"resetKeybindingsTooltip": "Réinitialiser les raccourcis clavier par défaut",
"resultsCount": "{count} Résultats Trouvés",
"save": "Enregistrer",
"searchExtensions": "Rechercher des extensions",
"searchFailedMessage": "Nous n'avons trouvé aucun paramètre correspondant à votre recherche. Essayez d'ajuster vos termes de recherche.",
@@ -187,9 +195,12 @@
"searchWorkflows": "Rechercher des flux de travail",
"settings": "Paramètres",
"showReport": "Afficher le rapport",
"sort": "Trier",
"status": "Statut",
"success": "Succès",
"systemInfo": "Informations système",
"terminal": "Terminal",
"updated": "Mis à jour",
"upload": "Téléverser",
"videoFailedToLoad": "Échec du chargement de la vidéo",
"workflow": "Flux de travail"
@@ -359,6 +370,43 @@
"terminalDefaultMessage": "Lorsque vous exécutez une commande de dépannage, toute sortie sera affichée ici.",
"title": "Maintenance"
},
"manager": {
"createdBy": "Créé par",
"discoverCommunityContent": "Découvrez les packs de nœuds, extensions et plus encore créés par la communauté...",
"downloads": "Téléchargements",
"errorConnecting": "Erreur de connexion au registre de nœuds Comfy.",
"filter": {
"disabled": "Désactivé",
"enabled": "Activé",
"nodePack": "Pack de Nœuds"
},
"installSelected": "Installer sélectionné",
"lastUpdated": "Dernière mise à jour",
"license": "Licence",
"noDescription": "Aucune description disponible",
"noResultsFound": "Aucun résultat trouvé correspondant à votre recherche.",
"nodePack": "Pack de Nœuds",
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
"searchPlaceholder": "Recherche",
"sort": {
"downloads": "Le plus populaire",
"rating": "Évaluation"
},
"status": {
"active": "Actif",
"banned": "Banni",
"deleted": "Supprimé",
"flagged": "Signalé",
"pending": "En attente",
"unknown": "Inconnu"
},
"title": "Gestionnaire de Nœuds Personnalisés",
"totalNodes": "Total de Nœuds",
"tryAgainLater": "Veuillez réessayer plus tard.",
"tryDifferentSearch": "Veuillez essayer une autre requête de recherche.",
"version": "Version"
},
"menu": {
"autoQueue": "File d'attente automatique",
"batchCount": "Nombre de lots",
@@ -398,6 +446,7 @@
"ComfyUI Forum": "Forum ComfyUI",
"ComfyUI Issues": "Problèmes de ComfyUI",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "デフォルトのワークフローを読み込む"
},
"Comfy_Manager_CustomNodesManager": {
"label": "カスタムノードマネージャ"
},
"Comfy_NewBlankWorkflow": {
"label": "新しい空のワークフロー"
},

View File

@@ -116,14 +116,17 @@
"g": {
"about": "情報",
"add": "追加",
"all": "すべて",
"back": "戻る",
"cancel": "キャンセル",
"capture": "キャプチャ",
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"close": "閉じる",
"color": "色",
"comingSoon": "近日公開",
"command": "コマンド",
"community": "コミュニティ",
"confirm": "確認",
"continue": "続ける",
"control_after_generate": "生成後の制御",
@@ -134,6 +137,7 @@
"customizeFolder": "フォルダーをカスタマイズ",
"delete": "削除",
"deprecated": "非推奨",
"description": "説明",
"devices": "デバイス",
"disableAll": "すべて無効にする",
"download": "ダウンロード",
@@ -144,6 +148,7 @@
"export": "エクスポート",
"extensionName": "拡張機能名",
"feedback": "フィードバック",
"filter": "フィルタ",
"findIssues": "問題を見つける",
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
"goToNode": "ノードに移動",
@@ -157,6 +162,7 @@
"loadWorkflow": "ワークフローを読み込む",
"loading": "読み込み中",
"logs": "ログ",
"name": "名前",
"newFolder": "新しいフォルダー",
"next": "次へ",
"no": "いいえ",
@@ -164,6 +170,7 @@
"noTasksFound": "タスクが見つかりません",
"noTasksFoundMessage": "キューにタスクがありません。",
"noWorkflowsFound": "ワークフローが見つかりません。",
"nodes": "ノード",
"ok": "OK",
"openNewIssue": "新しい問題を開く",
"overwrite": "上書き",
@@ -177,6 +184,7 @@
"reportSent": "レポートが送信されました",
"reset": "リセット",
"resetKeybindingsTooltip": "キーバインディングをデフォルトにリセット",
"resultsCount": "{count}件の結果が見つかりました",
"save": "保存",
"searchExtensions": "拡張機能を検索",
"searchFailedMessage": "検索に一致する設定が見つかりませんでした。検索キーワードを調整してみてください。",
@@ -187,9 +195,12 @@
"searchWorkflows": "ワークフローを検索",
"settings": "設定",
"showReport": "レポートを表示",
"sort": "並び替え",
"status": "ステータス",
"success": "成功",
"systemInfo": "システム情報",
"terminal": "ターミナル",
"updated": "更新済み",
"upload": "アップロード",
"videoFailedToLoad": "ビデオの読み込みに失敗しました",
"workflow": "ワークフロー"
@@ -359,6 +370,43 @@
"terminalDefaultMessage": "トラブルシューティングコマンドを実行すると、出力はここに表示されます。",
"title": "メンテナンス"
},
"manager": {
"createdBy": "作成者",
"discoverCommunityContent": "コミュニティが作成したノードパック、拡張機能などを探す...",
"downloads": "ダウンロード",
"errorConnecting": "Comfy Node Registryへの接続エラー。",
"filter": {
"disabled": "無効",
"enabled": "有効",
"nodePack": "ノードパック"
},
"installSelected": "選択したものをインストール",
"lastUpdated": "最終更新日",
"license": "ライセンス",
"noDescription": "説明はありません",
"noResultsFound": "検索に一致する結果が見つかりませんでした。",
"nodePack": "ノードパック",
"packsSelected": "選択したパック",
"repository": "リポジトリ",
"searchPlaceholder": "検索",
"sort": {
"downloads": "最も人気",
"rating": "評価"
},
"status": {
"active": "アクティブ",
"banned": "禁止",
"deleted": "削除済み",
"flagged": "フラグ付き",
"pending": "保留中",
"unknown": "不明"
},
"title": "カスタムノードマネージャ",
"totalNodes": "合計ノード数",
"tryAgainLater": "後ほど再試行してください。",
"tryDifferentSearch": "別の検索クエリを試してみてください。",
"version": "バージョン"
},
"menu": {
"autoQueue": "自動キュー",
"batchCount": "バッチ数",
@@ -398,6 +446,7 @@
"ComfyUI Forum": "ComfyUI フォーラム",
"ComfyUI Issues": "ComfyUIの問題",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "기본 워크플로 로드"
},
"Comfy_Manager_CustomNodesManager": {
"label": "사용자 정의 노드 관리자"
},
"Comfy_NewBlankWorkflow": {
"label": "새로운 빈 워크플로"
},

View File

@@ -116,14 +116,17 @@
"g": {
"about": "정보",
"add": "추가",
"all": "모두",
"back": "뒤로",
"cancel": "취소",
"capture": "캡처",
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"close": "닫기",
"color": "색상",
"comingSoon": "곧 출시 예정",
"command": "명령",
"community": "커뮤니티",
"confirm": "확인",
"continue": "계속",
"control_after_generate": "생성 후 제어",
@@ -134,6 +137,7 @@
"customizeFolder": "폴더 사용자 정의",
"delete": "삭제",
"deprecated": "사용 중단",
"description": "설명",
"devices": "장치",
"disableAll": "모두 비활성화",
"download": "다운로드",
@@ -144,6 +148,7 @@
"export": "내보내기",
"extensionName": "확장 이름",
"feedback": "피드백",
"filter": "필터",
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"goToNode": "노드로 이동",
@@ -157,6 +162,7 @@
"loadWorkflow": "워크플로 로드",
"loading": "로딩 중",
"logs": "로그",
"name": "이름",
"newFolder": "새 폴더",
"next": "다음",
"no": "아니오",
@@ -164,6 +170,7 @@
"noTasksFound": "작업을 찾을 수 없습니다.",
"noTasksFoundMessage": "대기열에 작업이 없습니다.",
"noWorkflowsFound": "워크플로를 찾을 수 없습니다.",
"nodes": "노드",
"ok": "확인",
"openNewIssue": "새 문제 열기",
"overwrite": "덮어쓰기",
@@ -177,6 +184,7 @@
"reportSent": "보고서 제출됨",
"reset": "재설정",
"resetKeybindingsTooltip": "키 바인딩을 기본값으로 재설정",
"resultsCount": "{count} 개의 결과를 찾았습니다",
"save": "저장",
"searchExtensions": "확장 검색",
"searchFailedMessage": "검색어와 일치하는 설정을 찾을 수 없습니다. 검색어를 조정해 보세요.",
@@ -187,9 +195,12 @@
"searchWorkflows": "워크플로 검색",
"settings": "설정",
"showReport": "보고서 보기",
"sort": "정렬",
"status": "상태",
"success": "성공",
"systemInfo": "시스템 정보",
"terminal": "터미널",
"updated": "업데이트됨",
"upload": "업로드",
"videoFailedToLoad": "비디오를 로드하지 못했습니다.",
"workflow": "워크플로"
@@ -359,6 +370,43 @@
"terminalDefaultMessage": "문제 해결 명령을 실행하면 출력이 여기에 표시됩니다.",
"title": "유지 보수"
},
"manager": {
"createdBy": "작성자",
"discoverCommunityContent": "커뮤니티에서 만든 노드 팩, 확장 프로그램 등을 찾아보세요...",
"downloads": "다운로드",
"errorConnecting": "Comfy Node Registry에 연결하는 중 오류가 발생했습니다.",
"filter": {
"disabled": "비활성화",
"enabled": "활성화",
"nodePack": "노드 팩"
},
"installSelected": "선택한 항목 설치",
"lastUpdated": "마지막 업데이트",
"license": "라이선스",
"noDescription": "설명이 없습니다",
"noResultsFound": "검색과 일치하는 결과가 없습니다.",
"nodePack": "노드 팩",
"packsSelected": "선택한 팩",
"repository": "저장소",
"searchPlaceholder": "검색",
"sort": {
"downloads": "가장 인기 있는",
"rating": "평점"
},
"status": {
"active": "활성",
"banned": "금지됨",
"deleted": "삭제됨",
"flagged": "플래그 표시됨",
"pending": "대기 중",
"unknown": "알 수 없음"
},
"title": "사용자 정의 노드 관리자",
"totalNodes": "총 노드",
"tryAgainLater": "나중에 다시 시도해 주세요.",
"tryDifferentSearch": "다른 검색어를 시도해 주세요.",
"version": "버전"
},
"menu": {
"autoQueue": "자동 실행 큐",
"batchCount": "배치 수",
@@ -398,6 +446,7 @@
"ComfyUI Forum": "ComfyUI 포럼",
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Загрузить стандартный рабочий процесс"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Менеджер Пользовательских Узлов"
},
"Comfy_NewBlankWorkflow": {
"label": "Новый пустой рабочий процесс"
},

View File

@@ -116,14 +116,17 @@
"g": {
"about": "О программе",
"add": "Добавить",
"all": "Все",
"back": "Назад",
"cancel": "Отмена",
"capture": "захват",
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"close": "Закрыть",
"color": "Цвет",
"comingSoon": "Скоро будет",
"command": "Команда",
"community": "Сообщество",
"confirm": "Подтвердить",
"continue": "Продолжить",
"control_after_generate": "управление после генерации",
@@ -134,6 +137,7 @@
"customizeFolder": "Настроить папку",
"delete": "Удалить",
"deprecated": "Устарело",
"description": "Описание",
"devices": "Устройства",
"disableAll": "Отключить все",
"download": "Скачать",
@@ -144,6 +148,7 @@
"export": "Экспорт",
"extensionName": "Название расширения",
"feedback": "Обратная связь",
"filter": "Фильтр",
"findIssues": "Найти проблемы",
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
"goToNode": "Перейти к ноде",
@@ -157,6 +162,7 @@
"loadWorkflow": "Загрузить рабочий процесс",
"loading": "Загрузка",
"logs": "Логи",
"name": "Имя",
"newFolder": "Новая папка",
"next": "Далее",
"no": "Нет",
@@ -164,6 +170,7 @@
"noTasksFound": "Задачи не найдены",
"noTasksFoundMessage": "В очереди нет задач.",
"noWorkflowsFound": "Рабочие процессы не найдены.",
"nodes": "Узлы",
"ok": "ОК",
"openNewIssue": "Открыть новую проблему",
"overwrite": "Перезаписать",
@@ -177,6 +184,7 @@
"reportSent": "Отчёт отправлен",
"reset": "Сбросить",
"resetKeybindingsTooltip": "Сбросить сочетания клавиш по умолчанию",
"resultsCount": "Найдено {count} результатов",
"save": "Сохранить",
"searchExtensions": "Поиск расширений",
"searchFailedMessage": "Мы не смогли найти настройки, соответствующие вашему запросу. Попробуйте изменить поисковые термины.",
@@ -187,9 +195,12 @@
"searchWorkflows": "Поиск рабочих процессов",
"settings": "Настройки",
"showReport": "Показать отчёт",
"sort": "Сортировать",
"status": "Статус",
"success": "Успех",
"systemInfo": "Информация о системе",
"terminal": "Терминал",
"updated": "Обновлено",
"upload": "Загрузить",
"videoFailedToLoad": "Не удалось загрузить видео",
"workflow": "Рабочий процесс"
@@ -359,6 +370,43 @@
"terminalDefaultMessage": "Когда вы запускаете команду для устранения неполадок, любой вывод будет отображаться здесь.",
"title": "Обслуживание"
},
"manager": {
"createdBy": "Создано",
"discoverCommunityContent": "Откройте для себя пакеты узлов, расширения и многое другое, созданные сообществом...",
"downloads": "Загрузки",
"errorConnecting": "Ошибка подключения к реестру Comfy Node.",
"filter": {
"disabled": "Отключено",
"enabled": "Включено",
"nodePack": "Пакет Узлов"
},
"installSelected": "Установить выбранное",
"lastUpdated": "Последнее обновление",
"license": "Лицензия",
"noDescription": "Описание отсутствует",
"noResultsFound": "По вашему запросу ничего не найдено.",
"nodePack": "Пакет Узлов",
"packsSelected": "Выбрано пакетов",
"repository": "Репозиторий",
"searchPlaceholder": "Поиск",
"sort": {
"downloads": "Самые популярные",
"rating": "Рейтинг"
},
"status": {
"active": "Активный",
"banned": "Заблокировано",
"deleted": "Удалено",
"flagged": "Отмечено",
"pending": "В ожидании",
"unknown": "Неизвестно"
},
"title": "Менеджер Пользовательских Узлов",
"totalNodes": "Всего Узлов",
"tryAgainLater": "Пожалуйста, попробуйте позже.",
"tryDifferentSearch": "Пожалуйста, попробуйте изменить запрос.",
"version": "Версия"
},
"menu": {
"autoQueue": "Автоочередь",
"batchCount": "Количество пакетов",
@@ -398,6 +446,7 @@
"ComfyUI Forum": "Форум ComfyUI",
"ComfyUI Issues": "Проблемы ComfyUI",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",

View File

@@ -125,6 +125,9 @@
"Comfy_LoadDefaultWorkflow": {
"label": "加载默认工作流"
},
"Comfy_Manager_CustomNodesManager": {
"label": "自定义节点管理器"
},
"Comfy_NewBlankWorkflow": {
"label": "新建空白工作流"
},

View File

@@ -116,14 +116,17 @@
"g": {
"about": "关于",
"add": "添加",
"all": "全部",
"back": "返回",
"cancel": "取消",
"capture": "捕获",
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"close": "关闭",
"color": "颜色",
"comingSoon": "即将推出",
"command": "指令",
"community": "社区",
"confirm": "确认",
"continue": "继续",
"control_after_generate": "生成后控制",
@@ -134,6 +137,7 @@
"customizeFolder": "自定义文件夹",
"delete": "删除",
"deprecated": "已弃用",
"description": "描述",
"devices": "设备",
"disableAll": "禁用全部",
"download": "下载",
@@ -144,6 +148,7 @@
"export": "导出",
"extensionName": "扩展名称",
"feedback": "反馈",
"filter": "过滤",
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"goToNode": "转到节点",
@@ -157,6 +162,7 @@
"loadWorkflow": "加载工作流",
"loading": "加载中",
"logs": "日志",
"name": "名称",
"newFolder": "新文件夹",
"next": "下一个",
"no": "否",
@@ -164,6 +170,7 @@
"noTasksFound": "未找到任务",
"noTasksFoundMessage": "队列中没有任务。",
"noWorkflowsFound": "未找到工作流。",
"nodes": "节点",
"ok": "确定",
"openNewIssue": "打开新问题",
"overwrite": "覆盖",
@@ -177,6 +184,7 @@
"reportSent": "报告已提交",
"reset": "重置",
"resetKeybindingsTooltip": "将快捷键重置为默认",
"resultsCount": "找到 {count} 个结果",
"save": "保存",
"searchExtensions": "搜索扩展",
"searchFailedMessage": "我们找不到任何与您的搜索匹配的设置。请尝试调整您的搜索词。",
@@ -187,9 +195,12 @@
"searchWorkflows": "搜索工作流",
"settings": "设置",
"showReport": "显示报告",
"sort": "排序",
"status": "状态",
"success": "成功",
"systemInfo": "系统信息",
"terminal": "终端",
"updated": "已更新",
"upload": "上传",
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
@@ -359,6 +370,43 @@
"terminalDefaultMessage": "当你运行一个故障排除命令时,任何输出都会在这里显示。",
"title": "维护"
},
"manager": {
"createdBy": "创建者",
"discoverCommunityContent": "发现社区制作的节点包,扩展等等...",
"downloads": "下载",
"errorConnecting": "连接到Comfy节点注册表时出错。",
"filter": {
"disabled": "已禁用",
"enabled": "已启用",
"nodePack": "节点包"
},
"installSelected": "安装选定",
"lastUpdated": "最后更新",
"license": "许可证",
"noDescription": "无可用描述",
"noResultsFound": "未找到符合您搜索的结果。",
"nodePack": "节点包",
"packsSelected": "选定的包",
"repository": "仓库",
"searchPlaceholder": "搜索",
"sort": {
"downloads": "最受欢迎",
"rating": "评级"
},
"status": {
"active": "活跃",
"banned": "已禁止",
"deleted": "已删除",
"flagged": "已标记",
"pending": "待定",
"unknown": "未知"
},
"title": "自定义节点管理器",
"totalNodes": "节点总数",
"tryAgainLater": "请稍后再试。",
"tryDifferentSearch": "请尝试不同的搜索查询。",
"version": "版本"
},
"menu": {
"autoQueue": "自动执行",
"batchCount": "批次数量",
@@ -398,6 +446,7 @@
"ComfyUI Forum": "ComfyUI 论坛",
"ComfyUI Issues": "ComfyUI 问题",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",

View File

@@ -23,6 +23,9 @@ export const useComfyRegistryService = () => {
const nodeDefStore = useNodeDefStore()
const isLocalNodePack = (nodePackId: string) =>
!!nodeDefStore.nodeDefsByName[nodePackId]
const isLocalNode = (nodeName: string, nodePackId: string) => {
if (!nodeDefStore.nodeDefsByName[nodeName]) return false
return (
@@ -31,6 +34,16 @@ export const useComfyRegistryService = () => {
)
}
/**
* Check if the node definitions for the pack are available
*/
const packNodesAvailable = (node: components['schemas']['Node']) => {
if (node.id && isLocalNodePack(node.id)) return true
if (node.latest_version?.comfy_node_extract_status !== 'success')
return false
return true
}
const handleApiError = (
err: unknown,
context: string,
@@ -100,24 +113,23 @@ export const useComfyRegistryService = () => {
}
/**
* Get Comfy Node definition for a specific node in a specific version of a node pack
* Get the Comfy Node definitions in a specific version of a node pack
* @param packId - The ID of the node pack
* @param versionId - The version of the node pack
* @param comfyNodeName - The name of the comfy node (corresponds to `ComfyNodeDef#name`)
* @returns The node definition or null if not found or an error occurred
* @returns The node definitions or null if not found or an error occurred
*/
const getNodeDef = async (
packId: components['schemas']['Node']['id'],
versionId: components['schemas']['NodeVersion']['id'],
comfyNodeName: components['schemas']['ComfyNode']['comfy_node_name'],
const getNodeDefs = async (
params: {
packId: components['schemas']['Node']['id']
versionId: components['schemas']['NodeVersion']['id']
},
signal?: AbortSignal
) => {
if (!comfyNodeName || !packId) return null
if (isLocalNode(comfyNodeName, packId))
return nodeDefStore.nodeDefsByName[comfyNodeName]
const { packId, versionId } = params
if (!packId || !versionId) return null
const endpoint = `/nodes/${packId}/versions/${versionId}/comfy-nodes/${comfyNodeName}`
const errorContext = 'Failed to get node definition'
const endpoint = `/nodes/${packId}/versions/${versionId}/comfy-nodes`
const errorContext = 'Failed to get node definitions'
const routeSpecificErrors = {
403: 'This pack has been banned and its definition is not available',
404: 'The requested node, version, or comfy node does not exist'
@@ -125,7 +137,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['ComfyNode']>(endpoint, {
registryApiClient.get<components['schemas']['ComfyNode'][]>(endpoint, {
signal
}),
errorContext,
@@ -133,6 +145,33 @@ export const useComfyRegistryService = () => {
)
}
/**
* Get a Comfy Node definition for a specific node in a specific version of a node pack
* @param packId - The ID of the node pack
* @param versionId - The version of the node pack
* @param comfyNodeName - The name of the comfy node (corresponds to `ComfyNodeDef#name`)
* @returns The node definition or null if not found or an error occurred
*/
const getNodeDef = async (
params: {
packId: components['schemas']['Node']['id']
versionId: components['schemas']['NodeVersion']['id']
comfyNodeName: components['schemas']['ComfyNode']['comfy_node_name']
},
signal?: AbortSignal
) => {
const { packId, versionId, comfyNodeName } = params
if (!comfyNodeName || !packId || !versionId) return null
if (isLocalNode(comfyNodeName, packId))
return nodeDefStore.nodeDefsByName[comfyNodeName]
const nodeDefs = await getNodeDefs({ packId, versionId }, signal)
return (
nodeDefs?.find((nodeDef) => nodeDef.comfy_node_name === comfyNodeName) ||
null
)
}
/**
* Get a paginated list of packs matching specific criteria.
* Search packs using `search` param. Search individual nodes using `comfy_node_search` param.
@@ -335,6 +374,8 @@ export const useComfyRegistryService = () => {
getPublisherById,
listPacksForPublisher,
getNodeDef,
postPackReview
getNodeDefs,
postPackReview,
packNodesAvailable
}
}

View File

@@ -5,6 +5,8 @@ import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import ManagerDialogContent from '@/components/dialog/content/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/components/dialog/content/manager/ManagerHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
@@ -101,6 +103,24 @@ export const useDialogService = () => {
})
}
function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
) {
dialogStore.showDialog({
key: 'global-manager',
component: ManagerDialogContent,
headerComponent: ManagerHeader,
dialogComponentProps: {
closable: false,
pt: {
header: { class: '!p-0 !m-0' },
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
}
},
props
})
}
async function prompt({
title,
message,
@@ -182,6 +202,7 @@ export const useDialogService = () => {
showExecutionErrorDialog,
showTemplateWorkflowsDialog,
showIssueReportDialog,
showManagerDialog,
prompt,
confirm
}

View File

@@ -12,6 +12,7 @@ type NodePack = components['schemas']['Node']
type ListPacksParams = operations['listAllNodes']['parameters']['query']
type ListPacksResult =
operations['listAllNodes']['responses'][200]['content']['application/json']
type ComfyNode = components['schemas']['ComfyNode']
/**
* Store for managing remote custom nodes
@@ -23,6 +24,9 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
typeof useCachedRequest<ListPacksParams, ListPacksResult>
>
let getPackByIdHandler: ReturnType<typeof useCachedRequest<string, NodePack>>
let getNodeDefsHandler: ReturnType<
typeof useCachedRequest<{ packId: string; versionId: string }, ComfyNode[]>
>
const recentListResult = ref<NodePack[]>([])
const hasPacks = computed(() => recentListResult.value.length > 0)
@@ -58,6 +62,23 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
return getPackByIdHandler.call(packId)
}
/**
* Get the node definitions for a pack
*/
const getNodeDefs = async (
packId: NodePack['id'],
versionId: NonNullable<NodePack['latest_version']>['id']
) => {
if (!packId || !versionId) return null
getNodeDefsHandler ??= useCachedRequest<
{ packId: string; versionId: string },
ComfyNode[]
>(registryService.getNodeDefs, { maxSize: PACK_BY_ID_CACHE_SIZE })
return getNodeDefsHandler.call({ packId, versionId })
}
/**
* Clear all cached data
*/
@@ -80,6 +101,8 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
listAllPacks,
getPackById,
getNodeDefs,
clearCache,
cancelRequests,

View File

@@ -11,6 +11,7 @@ interface CustomDialogComponentProps {
maximizable?: boolean
maximized?: boolean
onClose?: () => void
closable?: boolean
pt?: DialogPassThroughOptions
}

View File

@@ -0,0 +1,14 @@
import type { components } from '@/types/comfyRegistryTypes'
export interface TabItem {
id: string
label: string
icon: string
}
export type NodeField = keyof components['schemas']['Node'] | null
export interface SearchOption<T> {
id: T
label: string
}

View File

@@ -320,3 +320,11 @@ export const paramsToCacheKey = (params: unknown): string => {
*/
export const generateRandomSuffix = (): string =>
Math.random().toString(36).substring(2, 6)
/**
* Formats a number to a locale-specific string
* @param num The number to format
* @returns The formatted number or 'N/A' if the number is undefined
*/
export const formatNumber = (num?: number): string =>
num?.toLocaleString() ?? 'N/A'