mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
feat(templates): migrate cloud template dialog to hub list/detail API
Replace static index.json template loading on cloud with the hub
workflows API (GET /api/hub/workflows for listing, GET
/api/hub/workflows/{share_id} for workflow JSON).
- Add listHubWorkflows, listAllHubWorkflows, getHubWorkflowDetail to api.ts
- Create adapter to convert HubWorkflowSummary to TemplateInfo
- Branch on isCloud in workflowTemplatesStore.fetchCoreTemplates()
- Update thumbnail URL resolution for absolute hub URLs
- Update workflow JSON loading to use detail API via shareId
- Add shareId-based fallback in URL template loader
This commit is contained in:
@@ -57,6 +57,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/ingest-types": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -410,6 +410,9 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/ingest-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/ingest-types
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HubWorkflowSummary } from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
adaptHubWorkflowToTemplate,
|
||||
adaptHubWorkflowsToCategories
|
||||
} from './hubTemplateAdapter'
|
||||
|
||||
const makeMinimalSummary = (
|
||||
overrides?: Partial<HubWorkflowSummary>
|
||||
): HubWorkflowSummary => ({
|
||||
share_id: 'abc123',
|
||||
name: 'My Workflow',
|
||||
profile: { username: 'testuser' },
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('adaptHubWorkflowToTemplate', () => {
|
||||
it('maps core fields correctly', () => {
|
||||
const summary = makeMinimalSummary({
|
||||
description: 'A great workflow',
|
||||
thumbnail_url: 'https://cdn.example.com/thumb.webp',
|
||||
thumbnail_comparison_url: 'https://cdn.example.com/compare.webp',
|
||||
thumbnail_type: 'image_comparison',
|
||||
tutorial_url: 'https://example.com/tutorial',
|
||||
publish_time: '2025-03-01T00:00:00Z'
|
||||
})
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.name).toBe('abc123')
|
||||
expect(result.title).toBe('My Workflow')
|
||||
expect(result.description).toBe('A great workflow')
|
||||
expect(result.shareId).toBe('abc123')
|
||||
expect(result.thumbnailUrl).toBe('https://cdn.example.com/thumb.webp')
|
||||
expect(result.thumbnailComparisonUrl).toBe(
|
||||
'https://cdn.example.com/compare.webp'
|
||||
)
|
||||
expect(result.thumbnailVariant).toBe('compareSlider')
|
||||
expect(result.tutorialUrl).toBe('https://example.com/tutorial')
|
||||
expect(result.date).toBe('2025-03-01T00:00:00Z')
|
||||
expect(result.profile).toEqual({ username: 'testuser' })
|
||||
})
|
||||
|
||||
it('extracts display_name from LabelRef arrays', () => {
|
||||
const summary = makeMinimalSummary({
|
||||
tags: [
|
||||
{ name: 'video-gen', display_name: 'Video Generation' },
|
||||
{ name: 'image-gen', display_name: 'Image Generation' }
|
||||
],
|
||||
models: [{ name: 'flux', display_name: 'Flux' }],
|
||||
custom_nodes: [{ name: 'comfy-node-pack', display_name: 'ComfyNodePack' }]
|
||||
})
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.tags).toEqual(['Video Generation', 'Image Generation'])
|
||||
expect(result.models).toEqual(['Flux'])
|
||||
expect(result.requiresCustomNodes).toEqual(['comfy-node-pack'])
|
||||
})
|
||||
|
||||
it('extracts metadata fields', () => {
|
||||
const summary = makeMinimalSummary({
|
||||
metadata: {
|
||||
vram: 8_000_000_000,
|
||||
size: 4_500_000_000,
|
||||
open_source: true
|
||||
}
|
||||
})
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.vram).toBe(8_000_000_000)
|
||||
expect(result.size).toBe(4_500_000_000)
|
||||
expect(result.openSource).toBe(true)
|
||||
})
|
||||
|
||||
it('provides sensible defaults for missing fields', () => {
|
||||
const summary = makeMinimalSummary()
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.description).toBe('')
|
||||
expect(result.mediaType).toBe('image')
|
||||
expect(result.mediaSubtype).toBe('webp')
|
||||
expect(result.thumbnailVariant).toBeUndefined()
|
||||
expect(result.tags).toBeUndefined()
|
||||
expect(result.models).toBeUndefined()
|
||||
expect(result.vram).toBeUndefined()
|
||||
expect(result.size).toBeUndefined()
|
||||
expect(result.openSource).toBeUndefined()
|
||||
expect(result.date).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles null publish_time', () => {
|
||||
const summary = makeMinimalSummary({ publish_time: null })
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.date).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores non-numeric metadata values', () => {
|
||||
const summary = makeMinimalSummary({
|
||||
metadata: {
|
||||
vram: 'not a number' as unknown,
|
||||
size: null as unknown,
|
||||
open_source: 'yes' as unknown
|
||||
} as Record<string, unknown>
|
||||
})
|
||||
|
||||
const result = adaptHubWorkflowToTemplate(summary)
|
||||
|
||||
expect(result.vram).toBeUndefined()
|
||||
expect(result.size).toBeUndefined()
|
||||
expect(result.openSource).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptHubWorkflowsToCategories', () => {
|
||||
it('wraps templates in a single hub category', () => {
|
||||
const summaries = [
|
||||
makeMinimalSummary({ share_id: 'a', name: 'Workflow A' }),
|
||||
makeMinimalSummary({ share_id: 'b', name: 'Workflow B' })
|
||||
]
|
||||
|
||||
const result = adaptHubWorkflowsToCategories(summaries)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].moduleName).toBe('hub')
|
||||
expect(result[0].title).toBe('All')
|
||||
expect(result[0].templates).toHaveLength(2)
|
||||
expect(result[0].templates[0].name).toBe('a')
|
||||
expect(result[0].templates[1].name).toBe('b')
|
||||
})
|
||||
|
||||
it('returns empty templates for empty input', () => {
|
||||
const result = adaptHubWorkflowsToCategories([])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].templates).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { HubWorkflowSummary } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { TemplateInfo, WorkflowTemplates } from '../types/template'
|
||||
|
||||
/**
|
||||
* Maps a hub thumbnail_type to the frontend thumbnailVariant.
|
||||
*/
|
||||
function mapThumbnailVariant(
|
||||
thumbnailType?: 'image' | 'video' | 'image_comparison'
|
||||
): string | undefined {
|
||||
switch (thumbnailType) {
|
||||
case 'image_comparison':
|
||||
return 'compareSlider'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a typed numeric value from the hub metadata object.
|
||||
*/
|
||||
function getMetadataNumber(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): number | undefined {
|
||||
const value = metadata?.[key]
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a typed boolean value from the hub metadata object.
|
||||
*/
|
||||
function getMetadataBoolean(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): boolean | undefined {
|
||||
const value = metadata?.[key]
|
||||
return typeof value === 'boolean' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a hub workflow summary to a TemplateInfo compatible with
|
||||
* the existing template dialog infrastructure.
|
||||
*/
|
||||
export function adaptHubWorkflowToTemplate(
|
||||
summary: HubWorkflowSummary
|
||||
): TemplateInfo {
|
||||
return {
|
||||
name: summary.share_id,
|
||||
title: summary.name,
|
||||
description: summary.description ?? '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
thumbnailVariant: mapThumbnailVariant(summary.thumbnail_type),
|
||||
tags: summary.tags?.map((t) => t.display_name),
|
||||
models: summary.models?.map((m) => m.display_name),
|
||||
requiresCustomNodes: summary.custom_nodes?.map((cn) => cn.name),
|
||||
thumbnailUrl: summary.thumbnail_url,
|
||||
thumbnailComparisonUrl: summary.thumbnail_comparison_url,
|
||||
shareId: summary.share_id,
|
||||
profile: summary.profile,
|
||||
tutorialUrl: summary.tutorial_url,
|
||||
date: summary.publish_time ?? undefined,
|
||||
vram: getMetadataNumber(summary.metadata, 'vram'),
|
||||
size: getMetadataNumber(summary.metadata, 'size'),
|
||||
openSource: getMetadataBoolean(summary.metadata, 'open_source')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps adapted hub workflows into the WorkflowTemplates[] structure
|
||||
* expected by the store. Returns a single category containing all templates.
|
||||
*/
|
||||
export function adaptHubWorkflowsToCategories(
|
||||
summaries: HubWorkflowSummary[]
|
||||
): WorkflowTemplates[] {
|
||||
return [
|
||||
{
|
||||
moduleName: 'hub',
|
||||
title: 'All',
|
||||
templates: summaries.map(adaptHubWorkflowToTemplate)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -35,6 +35,21 @@ vi.mock(
|
||||
() => preservedQueryMocks
|
||||
)
|
||||
|
||||
// Mock the workflow templates store
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: vi.fn(() => ({
|
||||
getTemplateByShareId: vi.fn().mockReturnValue(undefined)
|
||||
}))
|
||||
})
|
||||
)
|
||||
|
||||
// Mock distribution (non-cloud for tests)
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
// Mock template workflows composable
|
||||
const mockLoadTemplates = vi.fn().mockResolvedValue(true)
|
||||
const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
|
||||
|
||||
@@ -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,11 +110,23 @@ export function useTemplateUrlLoader() {
|
||||
try {
|
||||
await templateWorkflows.loadTemplates()
|
||||
|
||||
const success = await templateWorkflows.loadWorkflowTemplate(
|
||||
let success = await templateWorkflows.loadWorkflowTemplate(
|
||||
templateParam,
|
||||
sourceParam
|
||||
)
|
||||
|
||||
// On cloud, if name-based lookup fails, try by shareId (hub templates)
|
||||
if (!success && isCloud) {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
const templateByShareId = store.getTemplateByShareId(templateParam)
|
||||
if (templateByShareId) {
|
||||
success = await templateWorkflows.loadWorkflowTemplate(
|
||||
templateByShareId.name,
|
||||
templateByShareId.sourceModule
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('useTemplateWorkflows', () => {
|
||||
mockWorkflowTemplatesStore = {
|
||||
isLoaded: false,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
getTemplateByName: vi.fn().mockReturnValue(undefined),
|
||||
groupedTemplates: [
|
||||
{
|
||||
label: 'ComfyUI Examples',
|
||||
|
||||
@@ -64,6 +64,15 @@ export function useTemplateWorkflows() {
|
||||
sourceModule: string,
|
||||
index = '1'
|
||||
) => {
|
||||
// Hub templates provide absolute thumbnail URLs
|
||||
if (template.thumbnailUrl) {
|
||||
if (index === '2' && template.thumbnailComparisonUrl) {
|
||||
return template.thumbnailComparisonUrl
|
||||
}
|
||||
return template.thumbnailUrl
|
||||
}
|
||||
|
||||
// Static path construction for local/desktop templates
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
@@ -124,6 +133,11 @@ export function useTemplateWorkflows() {
|
||||
sourceModule = template.sourceModule
|
||||
}
|
||||
|
||||
// Hub templates use sourceModule 'hub'
|
||||
if (sourceModule === 'hub') {
|
||||
sourceModule = 'default'
|
||||
}
|
||||
|
||||
// Regular case for normal categories
|
||||
json = await fetchTemplateJson(id, sourceModule)
|
||||
|
||||
@@ -157,6 +171,13 @@ export function useTemplateWorkflows() {
|
||||
* Fetches template JSON from the appropriate endpoint
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
// Hub templates: fetch workflow JSON via detail API using shareId
|
||||
const template = workflowTemplatesStore.getTemplateByName(id)
|
||||
if (isCloud && template?.shareId) {
|
||||
const detail = await api.getHubWorkflowDetail(template.shareId)
|
||||
return detail.workflow_json
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import { adaptHubWorkflowsToCategories } from '../adapters/hubTemplateAdapter'
|
||||
import { zLogoIndex } from '../schemas/templateSchema'
|
||||
import type { LogoIndex } from '../schemas/templateSchema'
|
||||
import type {
|
||||
@@ -25,6 +26,7 @@ interface EnhancedTemplate extends TemplateInfo {
|
||||
isEssential?: boolean
|
||||
isPartnerNode?: boolean // Computed from OpenSource === false
|
||||
searchableText?: string
|
||||
shareId?: string
|
||||
}
|
||||
|
||||
export const useWorkflowTemplatesStore = defineStore(
|
||||
@@ -41,6 +43,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
|
||||
@@ -473,10 +483,24 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
})
|
||||
|
||||
async function fetchCoreTemplates() {
|
||||
if (isCloud) {
|
||||
const summaries = await api.listAllHubWorkflows()
|
||||
coreTemplates.value = adaptHubWorkflowsToCategories(summaries)
|
||||
// Hub templates use absolute thumbnail URLs — no logo index needed
|
||||
// Hub has no i18n variant — skip english templates fetch
|
||||
|
||||
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 +607,7 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
loadWorkflowTemplates,
|
||||
knownTemplateNames,
|
||||
getTemplateByName,
|
||||
getTemplateByShareId,
|
||||
getEnglishMetadata,
|
||||
getLogoUrl
|
||||
}
|
||||
|
||||
@@ -64,6 +64,23 @@ export interface TemplateInfo {
|
||||
* Logo overlays to display on the template thumbnail.
|
||||
*/
|
||||
logos?: LogoInfo[]
|
||||
/**
|
||||
* Absolute URL to the primary thumbnail (from hub API).
|
||||
* When present, skip URL construction from name + mediaSubtype.
|
||||
*/
|
||||
thumbnailUrl?: string
|
||||
/**
|
||||
* Absolute URL to the comparison thumbnail (from hub API).
|
||||
*/
|
||||
thumbnailComparisonUrl?: string
|
||||
/**
|
||||
* Hub share ID for fetching workflow JSON via detail API.
|
||||
*/
|
||||
shareId?: string
|
||||
/**
|
||||
* Hub profile information for the template author.
|
||||
*/
|
||||
profile?: { username: string; display_name?: string; avatar_url?: string }
|
||||
}
|
||||
|
||||
export enum TemplateIncludeOnDistributionEnum {
|
||||
|
||||
@@ -60,6 +60,15 @@ import type {
|
||||
JobListItem
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type {
|
||||
HubWorkflowDetail,
|
||||
HubWorkflowListResponse,
|
||||
HubWorkflowSummary
|
||||
} from '@comfyorg/ingest-types'
|
||||
import {
|
||||
zHubWorkflowDetail,
|
||||
zHubWorkflowListResponse
|
||||
} from '@comfyorg/ingest-types/zod'
|
||||
import type { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
@@ -827,6 +836,65 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists hub workflows with optional filtering and pagination.
|
||||
*/
|
||||
async listHubWorkflows(params?: {
|
||||
cursor?: string
|
||||
limit?: number
|
||||
search?: string
|
||||
tag?: string
|
||||
}): Promise<HubWorkflowListResponse> {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.cursor) query.set('cursor', params.cursor)
|
||||
if (params?.limit) query.set('limit', String(params.limit))
|
||||
if (params?.search) query.set('search', params.search)
|
||||
if (params?.tag) query.set('tag', params.tag)
|
||||
const qs = query.toString()
|
||||
const res = await this.fetchApi(`/hub/workflows${qs ? `?${qs}` : ''}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to list hub workflows: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const parsed = zHubWorkflowListResponse.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid hub workflow list response')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all hub workflows by paginating through all pages.
|
||||
*/
|
||||
async listAllHubWorkflows(): Promise<HubWorkflowSummary[]> {
|
||||
const all: HubWorkflowSummary[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const res = await this.listHubWorkflows({ limit: 100, cursor })
|
||||
all.push(...(res.workflows as HubWorkflowSummary[]))
|
||||
cursor = res.next_cursor || undefined
|
||||
} while (cursor)
|
||||
return all
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets full details of a hub workflow including workflow JSON.
|
||||
*/
|
||||
async getHubWorkflowDetail(shareId: string): Promise<HubWorkflowDetail> {
|
||||
const res = await this.fetchApi(
|
||||
`/hub/workflows/${encodeURIComponent(shareId)}`
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get hub workflow detail: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const parsed = zHubWorkflowDetail.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid hub workflow detail response')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of embedding names
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user