feat(templates): use hub workflow index on cloud

This commit is contained in:
dante01yoon
2026-04-14 12:34:18 +09:00
parent c484c3984f
commit 0e7c4c1426
11 changed files with 608 additions and 6 deletions

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import type { HubWorkflowIndexEntry } from '../schemas/hubWorkflowIndexSchema'
import {
mapHubWorkflowIndexEntryToTemplate,
mapHubWorkflowIndexToCategories
} from './hubWorkflowIndexMapper'
const makeEntry = (
overrides?: Partial<HubWorkflowIndexEntry>
): HubWorkflowIndexEntry => ({
name: 'sdxl_simple',
title: 'SDXL Simple',
status: 'approved',
...overrides
})
describe('mapHubWorkflowIndexEntryToTemplate', () => {
it('maps template metadata used by the selector dialog', () => {
const result = mapHubWorkflowIndexEntryToTemplate(
makeEntry({
description: 'Starter SDXL workflow',
tags: ['Image', 'Text to Image'],
models: ['SDXL'],
requiresCustomNodes: ['comfy-custom-pack'],
thumbnailVariant: 'compareSlider',
mediaType: 'image',
mediaSubtype: 'webp',
size: 1024,
vram: 2048,
openSource: true,
tutorialUrl: 'https://docs.comfy.org/tutorials/sdxl',
logos: [
{
provider: 'OpenAI',
label: 'OpenAI',
opacity: 0.7
}
],
date: '2026-04-14',
includeOnDistributions: ['cloud', 'desktop', 'unsupported'],
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp',
shareId: 'share-123',
usage: 42,
searchRank: 7,
isEssential: true,
useCase: 'Image generation',
license: 'MIT'
})
)
expect(result).toEqual({
name: 'sdxl_simple',
title: 'SDXL Simple',
description: 'Starter SDXL workflow',
mediaType: 'image',
mediaSubtype: 'webp',
thumbnailVariant: 'compareSlider',
isEssential: true,
shareId: 'share-123',
tags: ['Image', 'Text to Image'],
models: ['SDXL'],
date: '2026-04-14',
useCase: 'Image generation',
license: 'MIT',
vram: 2048,
size: 1024,
openSource: true,
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp',
requiresCustomNodes: ['comfy-custom-pack'],
searchRank: 7,
usage: 42,
includeOnDistributions: ['cloud', 'desktop'],
logos: [{ provider: 'OpenAI', label: 'OpenAI', opacity: 0.7 }],
tutorialUrl: 'https://docs.comfy.org/tutorials/sdxl'
})
})
it('infers video thumbnails from preview URLs', () => {
const result = mapHubWorkflowIndexEntryToTemplate(
makeEntry({
mediaType: 'image',
mediaSubtype: 'webp',
thumbnailUrl: 'https://cdn.example.com/preview.mp4'
})
)
expect(result.mediaType).toBe('video')
expect(result.mediaSubtype).toBe('mp4')
})
it('drops invalid logo and distribution values', () => {
const result = mapHubWorkflowIndexEntryToTemplate(
makeEntry({
logos: [
{ provider: ['OpenAI', 'Runway'], gap: -4 },
{ provider: 123 }
] as Array<Record<string, unknown>>,
includeOnDistributions: ['local', 'desktop', 'invalid']
})
)
expect(result.logos).toEqual([{ provider: ['OpenAI', 'Runway'], gap: -4 }])
expect(result.includeOnDistributions).toEqual(['local', 'desktop'])
})
})
describe('mapHubWorkflowIndexToCategories', () => {
it('wraps flat hub entries in a single default category', () => {
const result = mapHubWorkflowIndexToCategories(
[
makeEntry({ name: 'template-a', title: 'Template A' }),
makeEntry({ name: 'template-b', title: 'Template B' })
],
'All Templates'
)
expect(result).toHaveLength(1)
expect(result[0].moduleName).toBe('default')
expect(result[0].title).toBe('All Templates')
expect(result[0].templates.map((template) => template.name)).toEqual([
'template-a',
'template-b'
])
})
})

View File

@@ -0,0 +1,137 @@
import { TemplateIncludeOnDistributionEnum } from '../types/template'
import type {
LogoInfo,
TemplateInfo,
WorkflowTemplates
} from '../types/template'
import type { HubWorkflowIndexEntry } from '../schemas/hubWorkflowIndexSchema'
const distributionValues = new Set(
Object.values(TemplateIncludeOnDistributionEnum)
)
function getPreviewExtension(url?: string): string | undefined {
if (!url) return undefined
try {
const { pathname } = new URL(url)
const extension = pathname.split('.').pop()?.toLowerCase()
return extension || undefined
} catch {
return undefined
}
}
function getPreviewMediaType(
thumbnailUrl?: string,
mediaType?: string
): string | undefined {
const extension = getPreviewExtension(thumbnailUrl)
if (extension && ['mp4', 'webm', 'mov'].includes(extension)) {
return 'video'
}
if (extension && ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(extension)) {
return 'image'
}
return mediaType
}
function getPreviewMediaSubtype(
thumbnailUrl?: string,
mediaSubtype?: string
): string {
return getPreviewExtension(thumbnailUrl) ?? mediaSubtype ?? 'webp'
}
function mapLogo(logo: Record<string, unknown>): LogoInfo | null {
const provider = logo.provider
if (
typeof provider !== 'string' &&
!(
Array.isArray(provider) &&
provider.length > 0 &&
provider.every((value) => typeof value === 'string')
)
) {
return null
}
return {
provider,
...(typeof logo.label === 'string' ? { label: logo.label } : {}),
...(typeof logo.gap === 'number' ? { gap: logo.gap } : {}),
...(typeof logo.position === 'string' ? { position: logo.position } : {}),
...(typeof logo.opacity === 'number' ? { opacity: logo.opacity } : {})
}
}
function mapLogos(
logos?: Array<Record<string, unknown>>
): LogoInfo[] | undefined {
const mapped = logos?.map(mapLogo).filter((logo): logo is LogoInfo => !!logo)
return mapped?.length ? mapped : undefined
}
function mapIncludeOnDistributions(
includeOnDistributions?: string[]
): TemplateIncludeOnDistributionEnum[] | undefined {
const mapped = includeOnDistributions?.filter(
(value): value is TemplateIncludeOnDistributionEnum =>
distributionValues.has(value as TemplateIncludeOnDistributionEnum)
)
return mapped?.length ? mapped : undefined
}
export function mapHubWorkflowIndexEntryToTemplate(
entry: HubWorkflowIndexEntry
): TemplateInfo {
return {
name: entry.name,
title: entry.title,
description: entry.description ?? '',
mediaType:
getPreviewMediaType(entry.thumbnailUrl, entry.mediaType) ?? 'image',
mediaSubtype: getPreviewMediaSubtype(
entry.thumbnailUrl,
entry.mediaSubtype
),
thumbnailVariant: entry.thumbnailVariant,
isEssential: entry.isEssential,
shareId: entry.shareId,
tags: entry.tags,
models: entry.models,
date: entry.date,
useCase: entry.useCase,
license: entry.license,
vram: entry.vram,
size: entry.size,
openSource: entry.openSource,
thumbnailUrl: entry.thumbnailUrl,
thumbnailComparisonUrl: entry.thumbnailComparisonUrl,
tutorialUrl: entry.tutorialUrl,
requiresCustomNodes: entry.requiresCustomNodes,
searchRank: entry.searchRank,
usage: entry.usage,
includeOnDistributions: mapIncludeOnDistributions(
entry.includeOnDistributions
),
logos: mapLogos(entry.logos)
}
}
export function mapHubWorkflowIndexToCategories(
entries: HubWorkflowIndexEntry[],
title: string = 'All'
): WorkflowTemplates[] {
return [
{
moduleName: 'default',
title,
templates: entries.map(mapHubWorkflowIndexEntryToTemplate)
}
]
}

View File

@@ -17,6 +17,15 @@ const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn()
}))
const distributionState = vi.hoisted(() => ({
isCloud: false
}))
const workflowTemplateStoreMocks = vi.hoisted(() => ({
getTemplateByName: vi.fn(),
getTemplateByShareId: vi.fn()
}))
// Mock vue-router
let mockQueryParams: Record<string, string | string[] | undefined> = {}
const mockRouterReplace = vi.fn()
@@ -35,6 +44,19 @@ vi.mock(
() => preservedQueryMocks
)
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distributionState.isCloud
}
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => workflowTemplateStoreMocks
})
)
// Mock template workflows composable
const mockLoadTemplates = vi.fn().mockResolvedValue(true)
const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
@@ -85,6 +107,7 @@ describe('useTemplateUrlLoader', () => {
vi.clearAllMocks()
mockQueryParams = {}
mockCanvasStore.linearMode = false
distributionState.isCloud = false
})
it('does not load template when no query param present', () => {
@@ -242,6 +265,21 @@ describe('useTemplateUrlLoader', () => {
})
})
it('resolves cloud template URLs by share id before loading', async () => {
distributionState.isCloud = true
mockQueryParams = { template: 'share-123' }
workflowTemplateStoreMocks.getTemplateByName.mockReturnValue(undefined)
workflowTemplateStoreMocks.getTemplateByShareId.mockReturnValue({
name: 'sdxl_simple',
sourceModule: 'hub'
})
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await loadTemplateFromUrl()
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith('sdxl_simple', 'hub')
})
it('removes template params from URL after successful load', async () => {
mockQueryParams = {
template: 'flux_simple',

View File

@@ -2,9 +2,11 @@ import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { isCloud } from '@/platform/distribution/types'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
// eslint-disable-next-line import-x/no-restricted-paths
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -108,9 +110,24 @@ export function useTemplateUrlLoader() {
try {
await templateWorkflows.loadTemplates()
let resolvedTemplate = templateParam
let resolvedSource = sourceParam
if (isCloud) {
const workflowTemplatesStore = useWorkflowTemplatesStore()
const matchedTemplate =
workflowTemplatesStore.getTemplateByName(templateParam) ??
workflowTemplatesStore.getTemplateByShareId(templateParam)
if (matchedTemplate) {
resolvedTemplate = matchedTemplate.name
resolvedSource = matchedTemplate.sourceModule ?? resolvedSource
}
}
const success = await templateWorkflows.loadWorkflowTemplate(
templateParam,
sourceParam
resolvedTemplate,
resolvedSource
)
if (!success) {

View File

@@ -15,6 +15,16 @@ vi.mock(
})
)
const distributionState = vi.hoisted(() => ({
isCloud: false
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distributionState.isCloud
}
}))
// Mock the API
vi.mock('@/scripts/api', () => ({
api: {
@@ -49,6 +59,14 @@ vi.mock('@/stores/dialogStore', () => ({
}))
}))
const mockGetSharedWorkflow = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getSharedWorkflow: mockGetSharedWorkflow
})
}))
// Mock fetch
global.fetch = vi.fn()
@@ -58,9 +76,20 @@ describe('useTemplateWorkflows', () => {
let mockWorkflowTemplatesStore: MockWorkflowTemplatesStore
beforeEach(() => {
distributionState.isCloud = false
mockWorkflowTemplatesStore = {
isLoaded: false,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
getTemplateByName: vi.fn((name: string) =>
name === 'template1'
? {
name: 'template1',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Template 1 description'
}
: undefined
),
groupedTemplates: [
{
label: 'ComfyUI Examples',
@@ -115,6 +144,16 @@ describe('useTemplateWorkflows', () => {
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as Partial<Response> as Response)
mockGetSharedWorkflow.mockResolvedValue({
shareId: 'share-123',
workflowId: 'workflow-123',
name: 'Shared Template',
listed: true,
publishedAt: null,
workflowJson: { workflow: 'shared' },
assets: []
})
})
it('should load templates from store', async () => {
@@ -178,6 +217,25 @@ describe('useTemplateWorkflows', () => {
)
})
it('should prefer absolute thumbnail URLs when provided', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'hub-template',
mediaSubtype: 'webp',
mediaType: 'image',
description: 'Hub template',
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp'
}
expect(getTemplateThumbnailUrl(template, 'hub', '1')).toBe(
'https://cdn.example.com/thumb.webp'
)
expect(getTemplateThumbnailUrl(template, 'hub', '2')).toBe(
'https://cdn.example.com/compare.webp'
)
})
it('should format template titles correctly', () => {
const { getTemplateTitle } = useTemplateWorkflows()
@@ -307,4 +365,27 @@ describe('useTemplateWorkflows', () => {
// Restore console.error
consoleSpy.mockRestore()
})
it('should load cloud templates by share id through the shared workflow service', async () => {
distributionState.isCloud = true
mockWorkflowTemplatesStore.isLoaded = true
vi.mocked(fetch).mockClear()
mockWorkflowTemplatesStore.getTemplateByName = vi.fn(() => ({
name: 'template1',
shareId: 'share-123',
sourceModule: 'default',
title: 'Template 1',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Template 1 description'
}))
const { loadWorkflowTemplate } = useTemplateWorkflows()
const result = await loadWorkflowTemplate('template1', 'hub')
expect(result).toBe(true)
expect(mockGetSharedWorkflow).toHaveBeenCalledWith('share-123')
expect(fetch).not.toHaveBeenCalled()
})
})

View File

@@ -9,6 +9,7 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
@@ -16,6 +17,7 @@ import { useDialogStore } from '@/stores/dialogStore'
export function useTemplateWorkflows() {
const { t } = useI18n()
const workflowTemplatesStore = useWorkflowTemplatesStore()
const workflowShareService = useWorkflowShareService()
const dialogStore = useDialogStore()
// State
@@ -64,6 +66,14 @@ export function useTemplateWorkflows() {
sourceModule: string,
index = '1'
) => {
if (template.thumbnailUrl) {
if (index === '2' && template.thumbnailComparisonUrl) {
return template.thumbnailComparisonUrl
}
return template.thumbnailUrl
}
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
@@ -157,6 +167,15 @@ export function useTemplateWorkflows() {
* Fetches template JSON from the appropriate endpoint
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
const template = workflowTemplatesStore.getTemplateByName(id)
if (isCloud && template?.shareId) {
const workflow = await workflowShareService.getSharedWorkflow(
template.shareId
)
return workflow.workflowJson
}
if (sourceModule === 'default') {
// Default templates provided by frontend are served on this separate endpoint
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())

View File

@@ -0,0 +1,100 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useWorkflowTemplatesStore } from './workflowTemplatesStore'
const distributionState = vi.hoisted(() => ({
isCloud: true
}))
const apiMocks = vi.hoisted(() => ({
getWorkflowTemplates: vi.fn(),
getHubWorkflowTemplateIndex: vi.fn(),
getCoreWorkflowTemplates: vi.fn(),
fileURL: vi.fn((path: string) => path)
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distributionState.isCloud
}
}))
vi.mock('@/scripts/api', () => ({
api: apiMocks
}))
vi.mock('@/i18n', () => ({
i18n: {
global: {
locale: ref('en')
}
},
st: (_key: string, fallback: string) => fallback
}))
describe('workflowTemplatesStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
distributionState.isCloud = true
apiMocks.getWorkflowTemplates.mockResolvedValue({})
apiMocks.getHubWorkflowTemplateIndex.mockResolvedValue([
{
name: 'starter-template',
title: 'Starter Template',
status: 'approved',
description: 'A cloud starter workflow',
shareId: 'share-123',
usage: 10,
searchRank: 5,
isEssential: true,
thumbnailUrl: 'https://cdn.example.com/thumb.webp'
}
])
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
headers: {
get: vi.fn(() => 'application/json')
},
json: vi.fn().mockResolvedValue({})
})
)
})
it('loads cloud templates from the hub index and resolves share ids', async () => {
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
const template = store.getTemplateByShareId('share-123')
expect(template?.name).toBe('starter-template')
expect(template?.shareId).toBe('share-123')
expect(store.knownTemplateNames.has('starter-template')).toBe(true)
})
it('creates a generic getting started nav item for essential cloud templates', async () => {
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
const navItem = store.navGroupedTemplates.find(
(item) => 'id' in item && item.id === 'basics-getting-started'
)
expect(navItem).toEqual({
id: 'basics-getting-started',
label: 'Getting Started',
icon: expect.any(String)
})
expect(
store
.filterTemplatesByCategory('basics-getting-started')
.map((template) => template.name)
).toEqual(['starter-template'])
})
})

View File

@@ -8,6 +8,7 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { mapHubWorkflowIndexToCategories } from '../adapters/hubWorkflowIndexMapper'
import { zLogoIndex } from '../schemas/templateSchema'
import type { LogoIndex } from '../schemas/templateSchema'
import type {
@@ -41,6 +42,14 @@ export const useWorkflowTemplatesStore = defineStore(
return enhancedTemplates.value.find((template) => template.name === name)
}
const getTemplateByShareId = (
shareId: string
): EnhancedTemplate | undefined => {
return enhancedTemplates.value.find(
(template) => template.shareId === shareId
)
}
// Store filter mappings for dynamic categories
type FilterData = {
category?: string
@@ -204,7 +213,7 @@ export const useWorkflowTemplatesStore = defineStore(
category: category.title,
categoryType: category.type,
categoryGroup: category.category,
isEssential: category.isEssential,
isEssential: template.isEssential ?? category.isEssential,
isPartnerNode: template.openSource === false,
searchableText: [
template.title || template.name,
@@ -261,12 +270,16 @@ export const useWorkflowTemplatesStore = defineStore(
}
if (categoryId.startsWith('basics-')) {
const basicsCategory = categoryId.replace('basics-', '')
// Filter for templates from categories marked as essential
return enhancedTemplates.value.filter(
(t) =>
t.isEssential &&
t.category?.toLowerCase().replace(/\s+/g, '-') ===
categoryId.replace('basics-', '')
(t.category?.toLowerCase().replace(/\s+/g, '-') ===
basicsCategory ||
(basicsCategory === 'getting-started' &&
(!t.category || t.sourceModule === 'default')))
)
}
@@ -355,6 +368,17 @@ export const useWorkflowTemplatesStore = defineStore(
getCategoryIcon(essentialCat.type || 'getting-started')
})
})
} else if (
enhancedTemplates.value.some((template) => template.isEssential)
) {
items.push({
id: generateCategoryId('basics', 'Getting Started'),
label: st(
'templateWorkflows.category.Getting Started',
'Getting Started'
),
icon: getCategoryIcon('getting-started')
})
}
// 3. Group categories from JSON dynamically
@@ -473,10 +497,31 @@ export const useWorkflowTemplatesStore = defineStore(
})
async function fetchCoreTemplates() {
if (isCloud) {
const [hubIndexResult, logoIndexResult] = await Promise.all([
api.getHubWorkflowTemplateIndex(),
fetchLogoIndex()
])
coreTemplates.value = mapHubWorkflowIndexToCategories(
hubIndexResult,
st('templateWorkflows.category.All', 'All')
)
englishTemplates.value = []
logoIndex.value = logoIndexResult
const coreNames = coreTemplates.value.flatMap((category) =>
category.templates.map((template) => template.name)
)
const customNames = Object.values(customTemplates.value).flat()
knownTemplateNames.value = new Set([...coreNames, ...customNames])
return
}
const locale = i18n.global.locale.value
const [coreResult, englishResult, logoIndexResult] = await Promise.all([
api.getCoreWorkflowTemplates(locale),
isCloud && locale !== 'en'
locale !== 'en'
? api.getCoreWorkflowTemplates('en')
: Promise.resolve([]),
fetchLogoIndex()
@@ -583,6 +628,7 @@ export const useWorkflowTemplatesStore = defineStore(
loadWorkflowTemplates,
knownTemplateNames,
getTemplateByName,
getTemplateByShareId,
getEnglishMetadata,
getLogoUrl
}

View File

@@ -0,0 +1,16 @@
import { zHubWorkflowTemplateEntry } from '@comfyorg/ingest-types/zod'
import { z } from 'zod'
// The live cloud index response currently includes fields that are not yet
// present in the generated ingest OpenAPI types.
export const zHubWorkflowIndexEntry = zHubWorkflowTemplateEntry.extend({
usage: z.number().optional(),
searchRank: z.number().optional(),
isEssential: z.boolean().optional(),
useCase: z.string().optional(),
license: z.string().optional()
})
export const zHubWorkflowIndexResponse = z.array(zHubWorkflowIndexEntry)
export type HubWorkflowIndexEntry = z.infer<typeof zHubWorkflowIndexEntry>

View File

@@ -26,6 +26,7 @@ export interface TemplateInfo {
localizedDescription?: string
isEssential?: boolean
sourceModule?: string
shareId?: string
tags?: string[]
models?: string[]
date?: string
@@ -40,6 +41,8 @@ export interface TemplateInfo {
* Whether this template uses open source models. When false, indicates partner/API node templates.
*/
openSource?: boolean
thumbnailUrl?: string
thumbnailComparisonUrl?: string
/**
* Array of custom node package IDs required for this template (from Custom Node Registry).
* Templates with this field will be hidden on local installations temporarily.

View File

@@ -21,6 +21,8 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { zHubWorkflowIndexResponse } from '@/platform/workflow/templates/schemas/hubWorkflowIndexSchema'
import type { HubWorkflowIndexEntry } from '@/platform/workflow/templates/schemas/hubWorkflowIndexSchema'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON,
@@ -827,6 +829,21 @@ export class ComfyApi extends EventTarget {
}
}
async getHubWorkflowTemplateIndex(): Promise<HubWorkflowIndexEntry[]> {
const res = await this.fetchApi('/hub/workflows/index')
if (!res.ok) {
throw new Error(`Failed to load hub workflow index: ${res.status}`)
}
const data = await res.json()
const parsed = zHubWorkflowIndexResponse.safeParse(data)
if (!parsed.success) {
throw new Error('Invalid hub workflow index response')
}
return parsed.data
}
/**
* Gets a list of embedding names
*/