diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts index 14e5c6768e..c9f2bc1a6b 100644 --- a/src/composables/useTemplateFiltering.ts +++ b/src/composables/useTemplateFiltering.ts @@ -1,56 +1,128 @@ +import Fuse from 'fuse.js' import { type Ref, computed, ref } from 'vue' import type { TemplateInfo } from '@/types/workflowTemplateTypes' export interface TemplateFilterOptions { searchQuery?: string + selectedModels?: string[] + sortBy?: 'recommended' | 'alphabetical' | 'newest' } export function useTemplateFiltering( templates: Ref | TemplateInfo[] ) { const searchQuery = ref('') + const selectedModels = ref([]) + const sortBy = ref<'recommended' | 'alphabetical' | 'newest'>('recommended') const templatesArray = computed(() => { const templateData = 'value' in templates ? templates.value : templates return Array.isArray(templateData) ? templateData : [] }) - const filteredTemplates = computed(() => { - const templateData = templatesArray.value - if (templateData.length === 0) { - return [] - } + // Fuse.js configuration for fuzzy search + const fuseOptions = { + keys: [ + { name: 'name', weight: 0.3 }, + { name: 'title', weight: 0.3 }, + { name: 'description', weight: 0.2 }, + { name: 'tags', weight: 0.1 }, + { name: 'models', weight: 0.1 } + ], + threshold: 0.4, + includeScore: true, + includeMatches: true + } + const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions)) + + const availableModels = computed(() => { + const modelSet = new Set() + templatesArray.value.forEach((template) => { + if (template.models && Array.isArray(template.models)) { + template.models.forEach((model) => modelSet.add(model)) + } + }) + return Array.from(modelSet).sort() + }) + + const filteredBySearch = computed(() => { if (!searchQuery.value.trim()) { - return templateData + return templatesArray.value } - const query = searchQuery.value.toLowerCase().trim() - return templateData.filter((template) => { - const searchableText = [ - template.name, - template.description, - template.sourceModule - ] - .filter(Boolean) - .join(' ') - .toLowerCase() + const results = fuse.value.search(searchQuery.value) + return results.map((result) => result.item) + }) - return searchableText.includes(query) + const filteredByModels = computed(() => { + if (selectedModels.value.length === 0) { + return filteredBySearch.value + } + + return filteredBySearch.value.filter((template) => { + if (!template.models || !Array.isArray(template.models)) { + return false + } + return selectedModels.value.some((selectedModel) => + template.models?.includes(selectedModel) + ) }) }) + const sortedTemplates = computed(() => { + const templates = [...filteredByModels.value] + + switch (sortBy.value) { + case 'alphabetical': + return templates.sort((a, b) => { + const nameA = a.title || a.name || '' + const nameB = b.title || b.name || '' + return nameA.localeCompare(nameB) + }) + case 'newest': + return templates.sort((a, b) => { + const dateA = new Date(a.date || '1970-01-01') + const dateB = new Date(b.date || '1970-01-01') + return dateB.getTime() - dateA.getTime() + }) + case 'recommended': + default: + // Keep original order (recommended order) + return templates + } + }) + + const filteredTemplates = computed(() => sortedTemplates.value) + const resetFilters = () => { searchQuery.value = '' + selectedModels.value = [] + sortBy.value = 'recommended' + } + + const removeModelFilter = (model: string) => { + selectedModels.value = selectedModels.value.filter((m) => m !== model) } const filteredCount = computed(() => filteredTemplates.value.length) + const totalCount = computed(() => templatesArray.value.length) return { + // State searchQuery, + selectedModels, + sortBy, + + // Computed filteredTemplates, + availableModels, filteredCount, - resetFilters + totalCount, + + // Methods + resetFilters, + removeModelFilter } } diff --git a/src/composables/useWorkflowTemplateSelectorDialog.ts b/src/composables/useWorkflowTemplateSelectorDialog.ts new file mode 100644 index 0000000000..bcb944a46e --- /dev/null +++ b/src/composables/useWorkflowTemplateSelectorDialog.ts @@ -0,0 +1,29 @@ +import WorkflowTemplateSelector from '@/components/custom/widget/WorkflowTemplateSelector.vue' +import { useDialogService } from '@/services/dialogService' +import { useDialogStore } from '@/stores/dialogStore' + +const DIALOG_KEY = 'global-workflow-template-selector' + +export const useWorkflowTemplateSelectorDialog = () => { + const dialogService = useDialogService() + const dialogStore = useDialogStore() + + function hide() { + dialogStore.closeDialog({ key: DIALOG_KEY }) + } + + function show() { + dialogService.showLayoutDialog({ + key: DIALOG_KEY, + component: WorkflowTemplateSelector, + props: { + onClose: hide + } + }) + } + + return { + show, + hide + } +} diff --git a/src/stores/workflowTemplatesStore.ts b/src/stores/workflowTemplatesStore.ts index 08220e004d..f190d39522 100644 --- a/src/stores/workflowTemplatesStore.ts +++ b/src/stores/workflowTemplatesStore.ts @@ -1,9 +1,10 @@ -import { groupBy } from 'es-toolkit/compat' +import Fuse from 'fuse.js' import { defineStore } from 'pinia' import { computed, ref, shallowRef } from 'vue' -import { st } from '@/i18n' +import { i18n, st } from '@/i18n' import { api } from '@/scripts/api' +import type { NavGroupData, NavItemData } from '@/types/navTypes' import type { TemplateGroup, TemplateInfo, @@ -11,13 +12,16 @@ import type { } from '@/types/workflowTemplateTypes' import { normalizeI18nKey } from '@/utils/formatUtil' -const SHOULD_SORT_CATEGORIES = new Set([ - // API Node templates should be strictly sorted by name to avoid any - // favoritism or bias towards a particular API. Other categories can - // have their ordering specified in index.json freely. - 'Image API', - 'Video API' -]) +// Enhanced template interface for easier filtering +interface EnhancedTemplate extends TemplateInfo { + sourceModule: string + category?: string + categoryType?: string + isAPI?: boolean + isPerformance?: boolean + isMacCompatible?: boolean + searchableText?: string +} export const useWorkflowTemplatesStore = defineStore( 'workflowTemplates', @@ -26,37 +30,6 @@ export const useWorkflowTemplatesStore = defineStore( const coreTemplates = shallowRef([]) const isLoaded = ref(false) - /** - * Sort a list of templates in alphabetical order by localized display name. - */ - const sortTemplateList = (templates: TemplateInfo[]) => - templates.sort((a, b) => { - const aName = st( - `templateWorkflows.name.${normalizeI18nKey(a.name)}`, - a.title ?? a.name - ) - const bName = st( - `templateWorkflows.name.${normalizeI18nKey(b.name)}`, - b.name - ) - return aName.localeCompare(bName) - }) - - /** - * Sort any template categories (grouped templates) that should be sorted. - * Leave other categories' templates in their original order specified in index.json - */ - const sortCategoryTemplates = (categories: WorkflowTemplates[]) => - categories.map((category) => { - if (SHOULD_SORT_CATEGORIES.has(category.title)) { - return { - ...category, - templates: sortTemplateList(category.templates) - } - } - return category - }) - /** * Add localization fields to a template. */ @@ -144,12 +117,13 @@ export const useWorkflowTemplatesStore = defineStore( } } + /** + * Original grouped templates for backward compatibility + */ const groupedTemplates = computed(() => { // Get regular categories const allTemplates = [ - ...sortCategoryTemplates(coreTemplates.value).map( - localizeTemplateCategory - ), + ...coreTemplates.value.map(localizeTemplateCategory), ...Object.entries(customTemplates.value).map( ([moduleName, templates]) => ({ moduleName, @@ -169,38 +143,330 @@ export const useWorkflowTemplatesStore = defineStore( ] // Group templates by their main category - const groupedByCategory = Object.entries( - groupBy(allTemplates, (template) => - template.moduleName === 'default' - ? st( - 'templateWorkflows.category.ComfyUI Examples', - 'ComfyUI Examples' - ) - : st('templateWorkflows.category.Custom Nodes', 'Custom Nodes') - ) - ).map(([label, modules]) => ({ label, modules })) - - // Insert the "All" category at the top of the "ComfyUI Examples" group - const comfyExamplesGroupIndex = groupedByCategory.findIndex( - (group) => - group.label === - st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples') - ) - - if (comfyExamplesGroupIndex !== -1) { - groupedByCategory[comfyExamplesGroupIndex].modules.unshift( - createAllCategory() - ) - } + const groupedByCategory = [ + { + label: st( + 'templateWorkflows.category.ComfyUI Examples', + 'ComfyUI Examples' + ), + modules: [ + createAllCategory(), + ...allTemplates.filter((t) => t.moduleName === 'default') + ] + }, + ...(Object.keys(customTemplates.value).length > 0 + ? [ + { + label: st( + 'templateWorkflows.category.Custom Nodes', + 'Custom Nodes' + ), + modules: allTemplates.filter((t) => t.moduleName !== 'default') + } + ] + : []) + ] return groupedByCategory }) + /** + * Enhanced templates with proper categorization for filtering + */ + const enhancedTemplates = computed(() => { + const allTemplates: EnhancedTemplate[] = [] + + // Process core templates + coreTemplates.value.forEach((category) => { + category.templates.forEach((template) => { + const isAPI = category.title?.includes('API') || false + const isPerformance = + template.models?.some( + (model) => + model.toLowerCase().includes('turbo') || + model.toLowerCase().includes('fast') || + model.toLowerCase().includes('schnell') || + model.toLowerCase().includes('fp8') + ) || false + + const isMacCompatible = + template.models?.some( + (model) => + model.toLowerCase().includes('fp8') || + model.toLowerCase().includes('turbo') || + model.toLowerCase().includes('schnell') + ) || false + + const enhancedTemplate: EnhancedTemplate = { + ...template, + sourceModule: category.moduleName, + category: category.title, + categoryType: category.type, + isAPI, + isPerformance, + isMacCompatible, + searchableText: [ + template.title || template.name, + template.description || '', + category.title, + ...(template.tags || []), + ...(template.models || []) + ].join(' ') + } + + allTemplates.push(enhancedTemplate) + }) + }) + + // Process custom templates + Object.entries(customTemplates.value).forEach( + ([moduleName, templates]) => { + templates.forEach((name) => { + const enhancedTemplate: EnhancedTemplate = { + name, + title: name, + description: name, + mediaType: 'image', + mediaSubtype: 'jpg', + sourceModule: moduleName, + category: 'Extensions', + categoryType: 'extension', + isAPI: false, + isPerformance: false, + isMacCompatible: false, + searchableText: `${name} ${moduleName} extension` + } + allTemplates.push(enhancedTemplate) + }) + } + ) + + return allTemplates + }) + + /** + * Fuse.js instance for advanced template searching and filtering + */ + const templateFuse = computed(() => { + const fuseOptions = { + keys: [ + { name: 'searchableText', weight: 0.4 }, + { name: 'title', weight: 0.3 }, + { name: 'name', weight: 0.2 }, + { name: 'tags', weight: 0.1 } + ], + threshold: 0.3, + includeScore: true + } + + return new Fuse(enhancedTemplates.value, fuseOptions) + }) + + /** + * Filter templates by category using Fuse.js + */ + const filterTemplatesByCategory = (categoryId: string) => { + if (categoryId === 'all') { + return enhancedTemplates.value + } + + switch (categoryId) { + case 'getting-started': + return enhancedTemplates.value.filter((t) => t.category === 'Basics') + + case 'generation-image': + return enhancedTemplates.value.filter( + (t) => t.categoryType === 'image' && !t.isAPI + ) + + case 'generation-video': + return enhancedTemplates.value.filter( + (t) => t.categoryType === 'video' && !t.isAPI + ) + + case 'generation-3d': + return enhancedTemplates.value.filter( + (t) => t.categoryType === '3d' && !t.isAPI + ) + + case 'generation-audio': + return enhancedTemplates.value.filter( + (t) => t.categoryType === 'audio' && !t.isAPI + ) + + case 'api-nodes': + return enhancedTemplates.value.filter((t) => t.isAPI) + + case 'extensions': + return enhancedTemplates.value.filter( + (t) => t.sourceModule !== 'default' + ) + + case 'performance-small': + return enhancedTemplates.value.filter((t) => t.isPerformance) + + case 'performance-mac': + return enhancedTemplates.value.filter((t) => t.isMacCompatible) + + default: + return enhancedTemplates.value + } + } + + /** + * New navigation structure matching NavItemData | NavGroupData format + */ + const navGroupedTemplates = computed<(NavItemData | NavGroupData)[]>(() => { + if (!isLoaded.value) return [] + + const items: (NavItemData | NavGroupData)[] = [] + + // Count templates for each category + const imageCounts = enhancedTemplates.value.filter( + (t) => t.categoryType === 'image' && !t.isAPI + ).length + const videoCounts = enhancedTemplates.value.filter( + (t) => t.categoryType === 'video' && !t.isAPI + ).length + const audioCounts = enhancedTemplates.value.filter( + (t) => t.categoryType === 'audio' && !t.isAPI + ).length + const threeDCounts = enhancedTemplates.value.filter( + (t) => t.categoryType === '3d' && !t.isAPI + ).length + const apiCounts = enhancedTemplates.value.filter((t) => t.isAPI).length + const gettingStartedCounts = enhancedTemplates.value.filter( + (t) => t.category === 'Basics' + ).length + const extensionCounts = enhancedTemplates.value.filter( + (t) => t.sourceModule !== 'default' + ).length + const performanceCounts = enhancedTemplates.value.filter( + (t) => t.isPerformance + ).length + const macCompatibleCounts = enhancedTemplates.value.filter( + (t) => t.isMacCompatible + ).length + + // All Templates - as a simple selector + items.push({ + id: 'all', + label: st('templateWorkflows.category.All', 'All Templates') + }) + + // Getting Started - as a simple selector + if (gettingStartedCounts > 0) { + items.push({ + id: 'getting-started', + label: st( + 'templateWorkflows.category.GettingStarted', + 'Getting Started' + ) + }) + } + + // Generation Type - as a group with sub-items + if ( + imageCounts > 0 || + videoCounts > 0 || + threeDCounts > 0 || + audioCounts > 0 + ) { + const generationTypeItems: NavItemData[] = [] + + if (imageCounts > 0) { + generationTypeItems.push({ + id: 'generation-image', + label: st('templateWorkflows.category.Image', 'Image') + }) + } + + if (videoCounts > 0) { + generationTypeItems.push({ + id: 'generation-video', + label: st('templateWorkflows.category.Video', 'Video') + }) + } + + if (threeDCounts > 0) { + generationTypeItems.push({ + id: 'generation-3d', + label: st('templateWorkflows.category.3DModels', '3D Models') + }) + } + + if (audioCounts > 0) { + generationTypeItems.push({ + id: 'generation-audio', + label: st('templateWorkflows.category.Audio', 'Audio') + }) + } + + items.push({ + title: st( + 'templateWorkflows.category.GenerationType', + 'Generation Type' + ), + items: generationTypeItems + }) + } + + // Closed Models (API nodes) - as a group + if (apiCounts > 0) { + items.push({ + title: st('templateWorkflows.category.ClosedModels', 'Closed Models'), + items: [ + { + id: 'api-nodes', + label: st('templateWorkflows.category.APINodes', 'API nodes') + } + ] + }) + } + + // Extensions - as a simple selector + if (extensionCounts > 0) { + items.push({ + id: 'extensions', + label: st('templateWorkflows.category.Extensions', 'Extensions') + }) + } + + // Performance - as a group + if (performanceCounts > 0) { + const performanceItems: NavItemData[] = [ + { + id: 'performance-small', + label: st('templateWorkflows.category.SmallModels', 'Small Models') + } + ] + + // Mac compatibility (only if there are compatible models) + if (macCompatibleCounts > 0) { + performanceItems.push({ + id: 'performance-mac', + label: st( + 'templateWorkflows.category.RunsOnMac', + 'Runs on Mac (Silicon)' + ) + }) + } + + items.push({ + title: st('templateWorkflows.category.Performance', 'Performance'), + items: performanceItems + }) + } + + return items + }) + async function loadWorkflowTemplates() { try { if (!isLoaded.value) { customTemplates.value = await api.getWorkflowTemplates() - coreTemplates.value = await api.getCoreWorkflowTemplates() + const locale = i18n.global.locale.value + coreTemplates.value = await api.getCoreWorkflowTemplates(locale) isLoaded.value = true } } catch (error) { @@ -210,6 +476,10 @@ export const useWorkflowTemplatesStore = defineStore( return { groupedTemplates, + navGroupedTemplates, + enhancedTemplates, + templateFuse, + filterTemplatesByCategory, isLoaded, loadWorkflowTemplates } diff --git a/src/stores/workflowTemplatesStoreOld.ts b/src/stores/workflowTemplatesStoreOld.ts new file mode 100644 index 0000000000..79172e1886 --- /dev/null +++ b/src/stores/workflowTemplatesStoreOld.ts @@ -0,0 +1,218 @@ +import { groupBy } from 'es-toolkit/compat' +import { defineStore } from 'pinia' +import { computed, ref, shallowRef } from 'vue' + +import { i18n, st } from '@/i18n' +import { api } from '@/scripts/api' +import type { + TemplateGroup, + TemplateInfo, + WorkflowTemplates +} from '@/types/workflowTemplateTypes' +import { normalizeI18nKey } from '@/utils/formatUtil' + +const SHOULD_SORT_CATEGORIES = new Set([ + // API Node templates should be strictly sorted by name to avoid any + // favoritism or bias towards a particular API. Other categories can + // have their ordering specified in index.json freely. + 'Image API', + 'Video API' +]) + +export const useWorkflowTemplatesStore = defineStore( + 'workflowTemplates', + () => { + const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({}) + const coreTemplates = shallowRef([]) + const isLoaded = ref(false) + + /** + * Sort a list of templates in alphabetical order by localized display name. + */ + const sortTemplateList = (templates: TemplateInfo[]) => + templates.sort((a, b) => { + const aName = st( + `templateWorkflows.name.${normalizeI18nKey(a.name)}`, + a.title ?? a.name + ) + const bName = st( + `templateWorkflows.name.${normalizeI18nKey(b.name)}`, + b.name + ) + return aName.localeCompare(bName) + }) + + /** + * Sort any template categories (grouped templates) that should be sorted. + * Leave other categories' templates in their original order specified in index.json + */ + const sortCategoryTemplates = (categories: WorkflowTemplates[]) => + categories.map((category) => { + if (SHOULD_SORT_CATEGORIES.has(category.title)) { + return { + ...category, + templates: sortTemplateList(category.templates) + } + } + return category + }) + + /** + * Add localization fields to a template. + */ + const addLocalizedFieldsToTemplate = ( + template: TemplateInfo, + categoryTitle: string + ) => ({ + ...template, + localizedTitle: st( + `templateWorkflows.template.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`, + template.title ?? template.name + ), + localizedDescription: st( + `templateWorkflows.templateDescription.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`, + template.description + ) + }) + + /** + * Add localization fields to all templates in a list of templates. + */ + const localizeTemplateList = ( + templates: TemplateInfo[], + categoryTitle: string + ) => + templates.map((template) => + addLocalizedFieldsToTemplate(template, categoryTitle) + ) + + /** + * Add localization fields to a template category and all its constituent templates. + */ + const localizeTemplateCategory = (templateCategory: WorkflowTemplates) => ({ + ...templateCategory, + localizedTitle: st( + `templateWorkflows.category.${normalizeI18nKey(templateCategory.title)}`, + templateCategory.title ?? templateCategory.moduleName + ), + templates: localizeTemplateList( + templateCategory.templates, + templateCategory.title + ) + }) + + // Create an "All" category that combines all templates + const createAllCategory = () => { + // First, get core templates with source module added + const coreTemplatesWithSourceModule = coreTemplates.value.flatMap( + (category) => + // For each template in each category, add the sourceModule and pass through any localized fields + category.templates.map((template) => { + // Get localized template with its original category title for i18n lookup + const localizedTemplate = addLocalizedFieldsToTemplate( + template, + category.title + ) + return { + ...localizedTemplate, + sourceModule: category.moduleName + } + }) + ) + + // Now handle custom templates + const customTemplatesWithSourceModule = Object.entries( + customTemplates.value + ).flatMap(([moduleName, templates]) => + templates.map((name) => ({ + name, + mediaType: 'image', + mediaSubtype: 'jpg', + description: name, + sourceModule: moduleName + })) + ) + + return { + moduleName: 'all', + title: 'All', + localizedTitle: st('templateWorkflows.category.All', 'All Templates'), + templates: [ + ...coreTemplatesWithSourceModule, + ...customTemplatesWithSourceModule + ] + } + } + + const groupedTemplates = computed(() => { + // Get regular categories + const allTemplates = [ + ...sortCategoryTemplates(coreTemplates.value).map( + localizeTemplateCategory + ), + ...Object.entries(customTemplates.value).map( + ([moduleName, templates]) => ({ + moduleName, + title: moduleName, + localizedTitle: st( + `templateWorkflows.category.${normalizeI18nKey(moduleName)}`, + moduleName + ), + templates: templates.map((name) => ({ + name, + mediaType: 'image', + mediaSubtype: 'jpg', + description: name + })) + }) + ) + ] + + // Group templates by their main category + const groupedByCategory = Object.entries( + groupBy(allTemplates, (template) => + template.moduleName === 'default' + ? st( + 'templateWorkflows.category.ComfyUI Examples', + 'ComfyUI Examples' + ) + : st('templateWorkflows.category.Custom Nodes', 'Custom Nodes') + ) + ).map(([label, modules]) => ({ label, modules })) + + // Insert the "All" category at the top of the "ComfyUI Examples" group + const comfyExamplesGroupIndex = groupedByCategory.findIndex( + (group) => + group.label === + st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples') + ) + + if (comfyExamplesGroupIndex !== -1) { + groupedByCategory[comfyExamplesGroupIndex].modules.unshift( + createAllCategory() + ) + } + + return groupedByCategory + }) + + async function loadWorkflowTemplates() { + try { + if (!isLoaded.value) { + customTemplates.value = await api.getWorkflowTemplates() + const locale = i18n.global.locale.value + coreTemplates.value = await api.getCoreWorkflowTemplates(locale) + isLoaded.value = true + } + } catch (error) { + console.error('Error fetching workflow templates:', error) + } + } + + return { + groupedTemplates, + isLoaded, + loadWorkflowTemplates + } + } +) diff --git a/src/types/workflowTemplateTypes.ts b/src/types/workflowTemplateTypes.ts index 9ff4e92730..1f62102fbb 100644 --- a/src/types/workflowTemplateTypes.ts +++ b/src/types/workflowTemplateTypes.ts @@ -12,12 +12,18 @@ export interface TemplateInfo { localizedTitle?: string localizedDescription?: string sourceModule?: string + tags?: string[] + models?: string[] + date?: string } export interface WorkflowTemplates { moduleName: string templates: TemplateInfo[] title: string + localizedTitle?: string + category?: string + type?: string } export interface TemplateGroup {