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:
dante01yoon
2026-03-28 21:51:42 +09:00
parent 4c59a5e424
commit 654167c980
11 changed files with 395 additions and 2 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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)
})
})

View File

@@ -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)
}
]
}

View File

@@ -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)

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,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',

View File

@@ -58,6 +58,7 @@ describe('useTemplateWorkflows', () => {
mockWorkflowTemplatesStore = {
isLoaded: false,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
getTemplateByName: vi.fn().mockReturnValue(undefined),
groupedTemplates: [
{
label: 'ComfyUI Examples',

View File

@@ -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())

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 { 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
}

View File

@@ -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 {

View File

@@ -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
*/