mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
feat(templates): use hub workflow index on cloud
This commit is contained in:
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user