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

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>