feat(templates): use server-side pagination and search for cloud

Replace loading all hub workflows at once with cursor-based pagination.
The store now loads one page at a time and fetches more on scroll via
the intersection observer.

Search on cloud delegates to the API search param instead of client-side
Fuse.js, resetting loaded data with server-filtered results.
This commit is contained in:
dante01yoon
2026-03-28 22:23:18 +09:00
parent 672aedd486
commit c0e32811e4
4 changed files with 165 additions and 33 deletions

View File

@@ -339,7 +339,7 @@
<!-- Loading More Skeletons -->
<CardContainer
v-for="n in isLoadingMore ? 6 : 0"
v-for="n in effectiveIsLoading ? 6 : 0"
:key="`skeleton-${n}`"
size="compact"
variant="ghost"
@@ -371,11 +371,11 @@
<!-- Load More Trigger -->
<div
v-if="!isLoading && hasMoreTemplates"
v-if="!isLoading && effectiveHasMore"
ref="loadTrigger"
class="mt-4 flex h-4 w-full items-center justify-center"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
<div v-if="effectiveIsLoading" class="text-sm text-muted">
{{ $t('templateWorkflows.loadingMore', 'Loading more...') }}
</div>
</div>
@@ -769,13 +769,35 @@ const {
// Display templates (all when searching, paginated when not)
const displayTemplates = computed(() => {
if (isCloud) {
// On cloud, all loaded templates are displayed (server handles pagination)
return filteredTemplates.value
}
return shouldUsePagination.value
? paginatedTemplates.value
: filteredTemplates.value
})
// Effective "has more" and "is loading" that unify client/server pagination
const effectiveHasMore = computed(() =>
isCloud ? workflowTemplatesStore.hubHasMore : hasMoreTemplates.value
)
const effectiveIsLoading = computed(() =>
isCloud ? workflowTemplatesStore.hubIsLoadingPage : isLoadingMore.value
)
// Set up intersection observer for lazy loading
useIntersectionObserver(loadTrigger, () => {
if (isCloud) {
if (
workflowTemplatesStore.hubHasMore &&
!workflowTemplatesStore.hubIsLoadingPage
) {
void workflowTemplatesStore.loadHubNextPage()
}
return
}
if (
shouldUsePagination.value &&
hasMoreTemplates.value &&

View File

@@ -4,9 +4,11 @@ import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import { debounce } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
@@ -121,11 +123,24 @@ export function useTemplateFiltering(
const debouncedSearchQuery = refDebounced(searchQuery, 150)
// On cloud, delegate search to the hub API instead of Fuse.js
if (isCloud) {
const workflowTemplatesStore = useWorkflowTemplatesStore()
watch(debouncedSearchQuery, (query) => {
void workflowTemplatesStore.searchHubWorkflows(query.trim())
})
}
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
return templatesArray.value
}
// On cloud, search is handled server-side — templates are already filtered
if (isCloud) {
return templatesArray.value
}
const results = fuse.value.search(debouncedSearchQuery.value)
return results.map((result) => result.item)
})

View File

@@ -8,7 +8,12 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { adaptHubWorkflowsToCategories } from '../adapters/hubTemplateAdapter'
import type { HubWorkflowSummary } from '@comfyorg/ingest-types'
import {
adaptHubWorkflowsToCategories,
adaptHubWorkflowToTemplate
} from '../adapters/hubTemplateAdapter'
import { zLogoIndex } from '../schemas/templateSchema'
import type { LogoIndex } from '../schemas/templateSchema'
import type {
@@ -39,6 +44,12 @@ export const useWorkflowTemplatesStore = defineStore(
const isLoaded = ref(false)
const knownTemplateNames = ref(new Set<string>())
// Hub pagination state (cloud only)
const hubNextCursor = ref<string | undefined>()
const hubHasMore = ref(false)
const hubIsLoadingPage = ref(false)
const hubSearchQuery = ref('')
const getTemplateByName = (name: string): EnhancedTemplate | undefined => {
return enhancedTemplates.value.find((template) => template.name === name)
}
@@ -482,18 +493,111 @@ export const useWorkflowTemplatesStore = defineStore(
return items
})
async function fetchCoreTemplates() {
if (isCloud) {
const summaries = await api.listAllHubWorkflows()
/**
* Appends hub workflow summaries to the existing coreTemplates.
*/
function appendHubWorkflows(summaries: HubWorkflowSummary[]) {
const newTemplates = summaries.map(adaptHubWorkflowToTemplate)
const existing = coreTemplates.value[0]
if (existing?.moduleName === 'hub') {
// Append to existing hub category
coreTemplates.value = [
{
...existing,
templates: [...existing.templates, ...newTemplates]
}
]
} else {
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])
}
/**
* Loads the next page of hub workflows (cloud only).
*/
async function loadHubNextPage() {
if (!isCloud || !hubHasMore.value || hubIsLoadingPage.value) return
hubIsLoadingPage.value = true
try {
const page = await api.fetchHubWorkflowPage({
limit: 20,
cursor: hubNextCursor.value,
search: hubSearchQuery.value || undefined
})
appendHubWorkflows(page.workflows as HubWorkflowSummary[])
hubNextCursor.value = page.next_cursor || undefined
hubHasMore.value = !!page.next_cursor
} catch (error) {
console.error('Error loading next hub page:', error)
} finally {
hubIsLoadingPage.value = false
}
}
/**
* Searches hub workflows via API (cloud only). Resets loaded data.
*/
async function searchHubWorkflows(query: string) {
if (!isCloud) return
hubSearchQuery.value = query
hubNextCursor.value = undefined
hubIsLoadingPage.value = true
try {
const page = await api.fetchHubWorkflowPage({
limit: 20,
search: query || undefined
})
// Replace all templates with search results
coreTemplates.value = adaptHubWorkflowsToCategories(
page.workflows as HubWorkflowSummary[]
)
hubNextCursor.value = page.next_cursor || undefined
hubHasMore.value = !!page.next_cursor
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])
} catch (error) {
console.error('Error searching hub workflows:', error)
} finally {
hubIsLoadingPage.value = false
}
}
async function fetchCoreTemplates() {
if (isCloud) {
// Load first page only — subsequent pages loaded via loadHubNextPage()
hubSearchQuery.value = ''
hubNextCursor.value = undefined
hubIsLoadingPage.value = true
try {
const page = await api.fetchHubWorkflowPage({ limit: 20 })
coreTemplates.value = adaptHubWorkflowsToCategories(
page.workflows as HubWorkflowSummary[]
)
hubNextCursor.value = page.next_cursor || undefined
hubHasMore.value = !!page.next_cursor
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])
} finally {
hubIsLoadingPage.value = false
}
return
}
@@ -609,7 +713,12 @@ export const useWorkflowTemplatesStore = defineStore(
getTemplateByName,
getTemplateByShareId,
getEnglishMetadata,
getLogoUrl
getLogoUrl,
// Hub pagination (cloud only)
hubHasMore,
hubIsLoadingPage,
loadHubNextPage,
searchHubWorkflows
}
}
)

View File

@@ -62,8 +62,7 @@ import type {
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type {
HubWorkflowDetail,
HubWorkflowListResponse,
HubWorkflowSummary
HubWorkflowListResponse
} from '@comfyorg/ingest-types'
import {
zHubWorkflowDetail,
@@ -837,16 +836,17 @@ export class ComfyApi extends EventTarget {
}
/**
* Fetches a single page of hub workflows. Only pagination params (limit, cursor)
* are sent — no filtering is applied so the caller always gets the full list.
* Fetches a single page of hub workflows with optional search.
*/
private async fetchHubWorkflowPage(
limit: number,
async fetchHubWorkflowPage(params?: {
limit?: number
cursor?: string
): Promise<HubWorkflowListResponse> {
search?: string
}): Promise<HubWorkflowListResponse> {
const query = new URLSearchParams()
query.set('limit', String(limit))
if (cursor) query.set('cursor', cursor)
query.set('limit', String(params?.limit ?? 20))
if (params?.cursor) query.set('cursor', params.cursor)
if (params?.search) query.set('search', params.search)
// TODO: Remove after production has approved data — fetch all statuses for testing
query.set('status', 'pending,approved,rejected,deprecated')
const res = await this.fetchApi(`/hub/workflows?${query.toString()}`)
@@ -861,20 +861,6 @@ export class ComfyApi extends EventTarget {
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 page = await this.fetchHubWorkflowPage(100, cursor)
all.push(...(page.workflows as HubWorkflowSummary[]))
cursor = page.next_cursor || undefined
} while (cursor)
return all
}
/**
* Gets full details of a hub workflow including workflow JSON.
*/