From 587d9526045d7ac86a8e12d9dd2c75ba9f4aebf3 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 10 Oct 2026 08:33:54 +0800 Subject: [PATCH] feat(recipes): add 'Has Workflow' filter to the recipes filter panel --- locales/de.json | 4 +- locales/en.json | 4 +- locales/es.json | 4 +- locales/fr.json | 4 +- locales/he.json | 4 +- locales/ja.json | 4 +- locales/ko.json | 4 +- locales/ru.json | 4 +- locales/zh-CN.json | 4 +- locales/zh-TW.json | 4 +- py/routes/handlers/recipe_handlers.py | 3 + py/services/recipe_scanner.py | 6 + static/js/api/recipeApi.js | 5 + static/js/managers/FilterManager.js | 38 ++- templates/components/header.html | 8 + .../FilterManager.hasWorkflow.test.js | 232 ++++++++++++++++++ tests/routes/test_recipe_routes.py | 25 ++ tests/services/test_recipe_scanner.py | 46 ++++ 18 files changed, 391 insertions(+), 12 deletions(-) create mode 100644 tests/frontend/managers/FilterManager.hasWorkflow.test.js diff --git a/locales/de.json b/locales/de.json index 9aaafe7e..b059fe35 100644 --- a/locales/de.json +++ b/locales/de.json @@ -297,7 +297,9 @@ "loraAvailability": "LoRA-Verfügbarkeit", "availabilityReady": "Einsatzbereit", "availabilityMissing": "Mit fehlenden LoRAs", - "availabilityDeleted": "Mit gelöschten LoRAs" + "availabilityDeleted": "Mit gelöschten LoRAs", + "workflow": "[TODO: Translate] Workflow", + "hasWorkflow": "[TODO: Translate] Has Workflow" }, "theme": { "toggle": "Theme wechseln", diff --git a/locales/en.json b/locales/en.json index ec26e99e..c6a6463a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -297,7 +297,9 @@ "loraAvailability": "Lora Availability", "availabilityReady": "Ready to use", "availabilityMissing": "Has missing", - "availabilityDeleted": "Has deleted" + "availabilityDeleted": "Has deleted", + "workflow": "Workflow", + "hasWorkflow": "Has Workflow" }, "theme": { "toggle": "Toggle theme", diff --git a/locales/es.json b/locales/es.json index 35f1cbd6..0194c645 100644 --- a/locales/es.json +++ b/locales/es.json @@ -297,7 +297,9 @@ "loraAvailability": "Disponibilidad de LoRAs", "availabilityReady": "Listos para usar", "availabilityMissing": "Con LoRAs faltantes", - "availabilityDeleted": "Con LoRAs eliminados" + "availabilityDeleted": "Con LoRAs eliminados", + "workflow": "[TODO: Translate] Workflow", + "hasWorkflow": "[TODO: Translate] Has Workflow" }, "theme": { "toggle": "Cambiar tema", diff --git a/locales/fr.json b/locales/fr.json index 66392892..1a1f880b 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -297,7 +297,9 @@ "loraAvailability": "Disponibilité des LoRAs", "availabilityReady": "Prêts à l'emploi", "availabilityMissing": "Avec LoRAs manquants", - "availabilityDeleted": "Avec LoRAs supprimés" + "availabilityDeleted": "Avec LoRAs supprimés", + "workflow": "[TODO: Translate] Workflow", + "hasWorkflow": "[TODO: Translate] Has Workflow" }, "theme": { "toggle": "Basculer le thème", diff --git a/locales/he.json b/locales/he.json index 2713af84..5fd519b0 100644 --- a/locales/he.json +++ b/locales/he.json @@ -297,7 +297,9 @@ "loraAvailability": "זמינות LoRA", "availabilityReady": "מוכנים לשימוש", "availabilityMissing": "עם LoRAs חסרים", - "availabilityDeleted": "עם LoRAs שנמחקו" + "availabilityDeleted": "עם LoRAs שנמחקו", + "workflow": "[TODO: Translate] Workflow", + "hasWorkflow": "[TODO: Translate] Has Workflow" }, "theme": { "toggle": "החלף ערכת נושא", diff --git a/locales/ja.json b/locales/ja.json index ae3f37c3..c649d33a 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -297,7 +297,9 @@ "loraAvailability": "LoRA の利用状況", "availabilityReady": "使用可能", "availabilityMissing": "不足 LoRA あり", - "availabilityDeleted": "削除済み LoRA あり" + "availabilityDeleted": "削除済み LoRA あり", + "workflow": "[TODO: Translate] Workflow", + "hasWorkflow": "[TODO: Translate] Has Workflow" }, "theme": { "toggle": "テーマの切り替え", diff --git a/locales/ko.json b/locales/ko.json index e132b7dc..91123da6 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -297,7 +297,9 @@ "loraAvailability": "LoRA 가용성", "availabilityReady": "바로 사용 가능", "availabilityMissing": "누락된 LoRA 있음", - "availabilityDeleted": "삭제된 LoRA 있음" + "availabilityDeleted": "삭제된 LoRA 있음", + "workflow": "[TODO: Translate] Workflow", + "hasWorkflow": "[TODO: Translate] Has Workflow" }, "theme": { "toggle": "테마 토글", diff --git a/locales/ru.json b/locales/ru.json index 59856844..1ba3ec53 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -297,7 +297,9 @@ "loraAvailability": "Доступность LoRAs", "availabilityReady": "Готовы к использованию", "availabilityMissing": "Есть отсутствующие", - "availabilityDeleted": "Есть удалённые" + "availabilityDeleted": "Есть удалённые", + "workflow": "[TODO: Translate] Workflow", + "hasWorkflow": "[TODO: Translate] Has Workflow" }, "theme": { "toggle": "Переключить тему", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 1960b297..b9ec3a43 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -297,7 +297,9 @@ "loraAvailability": "LoRA 可用性", "availabilityReady": "可直接使用", "availabilityMissing": "包含缺失 LoRA", - "availabilityDeleted": "包含已删除 LoRA" + "availabilityDeleted": "包含已删除 LoRA", + "workflow": "[TODO: Translate] Workflow", + "hasWorkflow": "[TODO: Translate] Has Workflow" }, "theme": { "toggle": "切换主题", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 6f1a8f6e..f198e20b 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -297,7 +297,9 @@ "loraAvailability": "LoRA 可用性", "availabilityReady": "可直接使用", "availabilityMissing": "包含缺少的 LoRA", - "availabilityDeleted": "包含已刪除的 LoRA" + "availabilityDeleted": "包含已刪除的 LoRA", + "workflow": "[TODO: Translate] Workflow", + "hasWorkflow": "[TODO: Translate] Has Workflow" }, "theme": { "toggle": "切換主題", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 59cedcd6..a67bf938 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -308,6 +308,9 @@ class RecipeListingHandler: if lora_availability: filters["lora_availability"] = lora_availability + if request.query.get("has_workflow", "").lower() == "true": + filters["has_workflow"] = True + lora_hash = request.query.get("lora_hash") checkpoint_hash = request.query.get("checkpoint_hash") diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 2900f043..dd4a6cce 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -3738,6 +3738,12 @@ class RecipeScanner: if self._compute_availability_statuses(item) & selected ] + # Filter by embedded workflow presence + if filters.get("has_workflow"): + filtered_data = [ + item for item in filtered_data if item.get("has_workflow") + ] + # Apply sorting if not already handled by pre-sorted cache if ":" in sort_by or sort_field in ("loras_count", "random", "opened"): field, order = (sort_by.split(":") + ["desc"])[:2] diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index f75ae6ba..cf4515aa 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -183,6 +183,11 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { if (pageState.filters?.loraAvailability && pageState.filters.loraAvailability.length > 0) { params.append('lora_availability', pageState.filters.loraAvailability.join(',')); } + + // Add has-workflow filter + if (pageState.filters?.hasWorkflow) { + params.append('has_workflow', 'true'); + } } // Fetch recipes diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 76640f33..29b18fc0 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -83,6 +83,11 @@ export class FilterManager { this.initializeLoraAvailabilityFilters(); } + // Add click handler for the has-workflow chip (recipes page only) + if (this.shouldShowLoraAvailabilityFilter()) { + this.initializeHasWorkflowFilter(); + } + // Initialize tag logic toggle this.initializeTagLogicToggle(); @@ -466,6 +471,29 @@ export class FilterManager { }); } + initializeHasWorkflowFilter() { + const hasWorkflowTags = document.querySelectorAll('.has-workflow-tag'); + hasWorkflowTags.forEach(tag => { + tag.addEventListener('click', async () => { + this.filters.hasWorkflow = !this.filters.hasWorkflow; + tag.classList.toggle('active', this.filters.hasWorkflow); + + this.updateActiveFiltersCount(); + await this.applyFilters(false); + }); + }); + + // Update selections based on stored filters + this.updateHasWorkflowSelection(); + } + + updateHasWorkflowSelection() { + const hasWorkflowTags = document.querySelectorAll('.has-workflow-tag'); + hasWorkflowTags.forEach(tag => { + tag.classList.toggle('active', Boolean(this.filters.hasWorkflow)); + }); + } + createBaseModelTags() { const baseModelTagsContainer = document.getElementById('baseModelTags'); if (!baseModelTagsContainer) return; @@ -732,6 +760,7 @@ export class FilterManager { // Update LoRA availability tags if visible on this page if (this.shouldShowLoraAvailabilityFilter()) { this.updateLoraAvailabilitySelections(); + this.updateHasWorkflowSelection(); } const autoTagEls = document.querySelectorAll('.auto-tag-filter'); @@ -763,7 +792,8 @@ export class FilterManager { const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length; // Active when at least one availability status is deselected const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0; - const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount + loraAvailabilityCount; + const hasWorkflowCount = this.filters.hasWorkflow ? 1 : 0; + const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount + loraAvailabilityCount + hasWorkflowCount; if (this.activeFiltersCount) { if (totalActiveFilters > 0) { @@ -861,6 +891,7 @@ export class FilterManager { license: {}, modelTypes: [], loraAvailability: [], + hasWorkflow: false, tagLogic: 'any' }); @@ -954,7 +985,8 @@ export class FilterManager { autoTagCount > 0 || licenseCount > 0 || modelTypeCount > 0 || - loraAvailabilityCount > 0 + loraAvailabilityCount > 0 || + Boolean(this.filters.hasWorkflow) ); } @@ -968,6 +1000,7 @@ export class FilterManager { license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {}, modelTypes: this.normalizeModelTypeFilters(source.modelTypes), loraAvailability: this.normalizeLoraAvailabilityFilters(source.loraAvailability), + hasWorkflow: Boolean(source.hasWorkflow), tagLogic: source.tagLogic || 'any' }; } @@ -1081,6 +1114,7 @@ export class FilterManager { license: { ...(this.filters.license || {}) }, modelTypes: [...(this.filters.modelTypes || [])], loraAvailability: [...(this.filters.loraAvailability || [])], + hasWorkflow: Boolean(this.filters.hasWorkflow), tagLogic: this.filters.tagLogic || 'any', search: pageState?.filters?.search ?? '' }; diff --git a/templates/components/header.html b/templates/components/header.html index de9d5a36..3c52e543 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -287,6 +287,14 @@ +
+

{{ t('header.filter.workflow') }}

+
+
+ {{ t('header.filter.hasWorkflow') }} +
+
+
{% endif %} {% if current_page == 'loras' or current_page == 'checkpoints' or current_page == 'other' %}
diff --git a/tests/frontend/managers/FilterManager.hasWorkflow.test.js b/tests/frontend/managers/FilterManager.hasWorkflow.test.js new file mode 100644 index 00000000..d950b714 --- /dev/null +++ b/tests/frontend/managers/FilterManager.hasWorkflow.test.js @@ -0,0 +1,232 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock dependencies +vi.mock('../../../static/js/state/index.js', () => ({ + getCurrentPageState: vi.fn(() => ({ + filters: {}, + })), + state: { + currentPageType: 'recipes', + loadingManager: { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + }, + }, +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: vi.fn(), + updatePanelPositions: vi.fn(), +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + getModelApiClient: vi.fn(() => ({ + loadMoreWithVirtualScroll: vi.fn().mockResolvedValue(), + })), +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + getStorageItem: vi.fn(), + setStorageItem: vi.fn(), + removeStorageItem: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: vi.fn((key, _params, fallback) => fallback || key), +})); + +vi.mock('../../../static/js/managers/FilterPresetManager.js', () => ({ + FilterPresetManager: vi.fn().mockImplementation(() => ({ + renderPresets: vi.fn(), + saveActivePreset: vi.fn(), + restoreActivePreset: vi.fn(), + updateAddButtonState: vi.fn(), + hasEmptyWildcardResult: vi.fn(() => false), + })), + EMPTY_WILDCARD_MARKER: '__EMPTY_WILDCARD_RESULT__', +})); + +import { FilterManager } from '../../../static/js/managers/FilterManager.js'; +import { getStorageItem } from '../../../static/js/utils/storageHelpers.js'; + +describe('FilterManager - Has Workflow', () => { + let manager; + let mockFilterPanel; + let mockActiveFiltersCount; + + function createHasWorkflowTag() { + const container = document.createElement('div'); + container.id = 'hasWorkflowTags'; + const tag = document.createElement('div'); + tag.className = 'filter-tag has-workflow-tag'; + container.appendChild(tag); + document.body.appendChild(container); + return tag; + } + + beforeEach(() => { + vi.clearAllMocks(); + getStorageItem.mockReturnValue(undefined); + document.body.innerHTML = ''; + + mockFilterPanel = document.createElement('div'); + mockFilterPanel.id = 'filterPanel'; + mockFilterPanel.classList.add('hidden'); + document.body.appendChild(mockFilterPanel); + + mockActiveFiltersCount = document.createElement('span'); + createHasWorkflowTag(); + + const originalGetElementById = document.getElementById; + document.getElementById = vi.fn((id) => { + if (id === 'filterPanel') return mockFilterPanel; + if (id === 'filterButton') return document.createElement('button'); + if (id === 'activeFiltersCount') return mockActiveFiltersCount; + if (id === 'baseModelTags') return document.createElement('div'); + if (id === 'modelTypeTags') return document.createElement('div'); + return originalGetElementById.call(document, id); + }); + }); + + describe('initializeFilters', () => { + it('should default to false on the recipes page', () => { + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.filters.hasWorkflow).toBe(false); + }); + + it('should restore a saved true value from storage', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + hasWorkflow: true, + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.filters.hasWorkflow).toBe(true); + }); + + it('should coerce a truthy stored value to boolean', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + hasWorkflow: 1, + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.filters.hasWorkflow).toBe(true); + }); + }); + + describe('hasActiveFilters', () => { + it('should be inactive when hasWorkflow is off', () => { + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.hasActiveFilters()).toBe(false); + }); + + it('should be active when hasWorkflow is on', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + hasWorkflow: true, + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(manager.hasActiveFilters()).toBe(true); + }); + }); + + describe('updateActiveFiltersCount', () => { + it('should count hasWorkflow as one active filter', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + hasWorkflow: true, + }); + + manager = new FilterManager({ page: 'recipes' }); + + expect(mockActiveFiltersCount.textContent).toBe('1'); + }); + }); + + describe('chip interaction', () => { + it('should activate the filter when the chip is clicked', async () => { + manager = new FilterManager({ page: 'recipes' }); + + const tag = document.querySelector('.has-workflow-tag'); + expect(tag.classList.contains('active')).toBe(false); + + tag.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(manager.filters.hasWorkflow).toBe(true); + expect(tag.classList.contains('active')).toBe(true); + }); + + it('should deactivate the filter when the chip is clicked again', async () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + hasWorkflow: true, + }); + + manager = new FilterManager({ page: 'recipes' }); + + const tag = document.querySelector('.has-workflow-tag'); + // Restored state should mark the chip active + expect(tag.classList.contains('active')).toBe(true); + + tag.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(manager.filters.hasWorkflow).toBe(false); + expect(tag.classList.contains('active')).toBe(false); + }); + }); + + describe('cloneFilters', () => { + it('should include hasWorkflow in cloned filters', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + hasWorkflow: true, + }); + + manager = new FilterManager({ page: 'recipes' }); + + const cloned = manager.cloneFilters(); + + expect(cloned.hasWorkflow).toBe(true); + }); + + it('should clone an unset hasWorkflow as false', () => { + manager = new FilterManager({ page: 'recipes' }); + + const cloned = manager.cloneFilters(); + + expect(cloned.hasWorkflow).toBe(false); + }); + }); + + describe('clearFilters', () => { + it('should reset hasWorkflow to false', () => { + getStorageItem.mockReturnValue({ + baseModel: [], + tags: {}, + hasWorkflow: true, + }); + + manager = new FilterManager({ page: 'recipes' }); + expect(manager.filters.hasWorkflow).toBe(true); + + manager.clearFilters(); + + expect(manager.filters.hasWorkflow).toBe(false); + }); + }); +}); diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index a5749fe3..31fa67e0 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -758,6 +758,31 @@ async def test_list_recipes_ignores_invalid_lora_availability_values( assert "lora_availability" not in filters +async def test_list_recipes_passes_has_workflow_filter( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.get("/api/lm/recipes?has_workflow=true") + payload = await response.json() + + assert response.status == 200 + assert payload["items"] == [] + assert harness.scanner.last_paginated_params is not None + filters = harness.scanner.last_paginated_params["filters"] + assert filters["has_workflow"] is True + + +async def test_list_recipes_ignores_non_true_has_workflow( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.get("/api/lm/recipes?has_workflow=bogus") + assert response.status == 200 + assert harness.scanner.last_paginated_params is not None + filters = harness.scanner.last_paginated_params["filters"] + assert "has_workflow" not in filters + + async def test_get_recipes_for_checkpoint(monkeypatch, tmp_path: Path) -> None: async with recipe_harness(monkeypatch, tmp_path) as harness: harness.scanner.checkpoint_lookup["abc123"] = [ diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index 2a234b0c..e3f72eb9 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -2064,6 +2064,52 @@ async def test_get_paginated_data_filters_by_favorite(recipe_scanner): assert len(result_fav_false["items"]) == 2 +@pytest.mark.asyncio +async def test_get_paginated_data_filters_by_has_workflow(recipe_scanner): + scanner, _ = recipe_scanner + + await scanner.add_recipe( + { + "id": "no-workflow", + "file_path": "path/no-workflow.png", + "title": "No Workflow Recipe", + "modified": 1.0, + "created_date": 1.0, + "loras": [], + "has_workflow": False, + } + ) + + await scanner.add_recipe( + { + "id": "with-workflow", + "file_path": "path/with-workflow.png", + "title": "Workflow Recipe", + "modified": 2.0, + "created_date": 2.0, + "loras": [], + "has_workflow": True, + } + ) + + await asyncio.sleep(0) + await _wait_for_resort(scanner) + + result_all = await scanner.get_paginated_data(page=1, page_size=10) + assert len(result_all["items"]) == 2 + + result_wf = await scanner.get_paginated_data( + page=1, page_size=10, filters={"has_workflow": True} + ) + assert len(result_wf["items"]) == 1 + assert result_wf["items"][0]["id"] == "with-workflow" + + result_wf_false = await scanner.get_paginated_data( + page=1, page_size=10, filters={"has_workflow": False} + ) + assert len(result_wf_false["items"]) == 2 + + @pytest.mark.asyncio async def test_get_paginated_data_filters_by_base_model_unknown_bucket(recipe_scanner): scanner, _ = recipe_scanner