From 9a789f8f083f27b2fbebd417109a1dc1ade829ea Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 21 Nov 2025 16:17:01 +0800 Subject: [PATCH] feat: add checkpoint hash filtering and navigation - Add checkpoint hash parameter parsing to backend routes - Implement checkpoint hash filtering in frontend API client - Add click navigation from recipe modal to checkpoints page - Update checkpoint items to use pointer cursor for better UX Checkpoint items in recipe modal are now clickable and will navigate to the checkpoints page with appropriate hash filtering applied. This improves user workflow when wanting to view checkpoint details from recipes. --- locales/de.json | 6 ++ locales/en.json | 6 ++ locales/es.json | 6 ++ locales/fr.json | 6 ++ locales/he.json | 6 ++ locales/ja.json | 6 ++ locales/ko.json | 6 ++ locales/ru.json | 6 ++ locales/zh-CN.json | 6 ++ locales/zh-TW.json | 6 ++ py/routes/checkpoint_routes.py | 14 ++++ static/css/components/recipe-modal.css | 2 +- static/js/api/baseModelApi.js | 15 +++++ static/js/components/RecipeModal.js | 46 ++++++++++++- .../controls/CheckpointsControls.js | 64 +++++++++++++++++-- .../components/pageControls.filtering.test.js | 33 ++++++++++ 16 files changed, 227 insertions(+), 7 deletions(-) diff --git a/locales/de.json b/locales/de.json index 3ff8cdd4..c6c1bdc8 100644 --- a/locales/de.json +++ b/locales/de.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "LoRA-Rezepte", + "actions": { + "sendCheckpoint": "Send to ComfyUI" + }, "controls": { "import": { "action": "Importieren", @@ -1254,6 +1257,9 @@ "cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID", "sendFailed": "Fehler beim Senden des Rezepts an Workflow", "sendError": "Fehler beim Senden des Rezepts an Workflow", + "missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar", + "missingCheckpointInfo": "Checkpoint-Informationen fehlen", + "downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}", "cannotDelete": "Kann Rezept nicht löschen: Fehlende Rezept-ID", "deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung", "deletedSuccessfully": "Rezept erfolgreich gelöscht", diff --git a/locales/en.json b/locales/en.json index 4596e45f..dde88b82 100644 --- a/locales/en.json +++ b/locales/en.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "LoRA Recipes", + "actions": { + "sendCheckpoint": "Send to ComfyUI" + }, "controls": { "import": { "action": "Import", @@ -1254,6 +1257,9 @@ "cannotSend": "Cannot send recipe: Missing recipe ID", "sendFailed": "Failed to send recipe to workflow", "sendError": "Error sending recipe to workflow", + "missingCheckpointPath": "Checkpoint path not available", + "missingCheckpointInfo": "Missing checkpoint information", + "downloadCheckpointFailed": "Failed to download checkpoint: {message}", "cannotDelete": "Cannot delete recipe: Missing recipe ID", "deleteConfirmationError": "Error showing delete confirmation", "deletedSuccessfully": "Recipe deleted successfully", diff --git a/locales/es.json b/locales/es.json index dcdfb26b..08596127 100644 --- a/locales/es.json +++ b/locales/es.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "Recetas de LoRA", + "actions": { + "sendCheckpoint": "Enviar a ComfyUI" + }, "controls": { "import": { "action": "Importar", @@ -1254,6 +1257,9 @@ "cannotSend": "No se puede enviar receta: Falta ID de receta", "sendFailed": "Error al enviar receta al flujo de trabajo", "sendError": "Error enviando receta al flujo de trabajo", + "missingCheckpointPath": "Ruta del checkpoint no disponible", + "missingCheckpointInfo": "Falta información del checkpoint", + "downloadCheckpointFailed": "Error al descargar el checkpoint: {message}", "cannotDelete": "No se puede eliminar receta: Falta ID de receta", "deleteConfirmationError": "Error mostrando confirmación de eliminación", "deletedSuccessfully": "Receta eliminada exitosamente", diff --git a/locales/fr.json b/locales/fr.json index 90452dc9..275d0d5c 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "LoRA Recipes", + "actions": { + "sendCheckpoint": "Envoyer vers ComfyUI" + }, "controls": { "import": { "action": "Importer", @@ -1254,6 +1257,9 @@ "cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant", "sendFailed": "Échec de l'envoi de la recipe vers le workflow", "sendError": "Erreur lors de l'envoi de la recipe vers le workflow", + "missingCheckpointPath": "Chemin du checkpoint indisponible", + "missingCheckpointInfo": "Informations sur le checkpoint manquantes", + "downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}", "cannotDelete": "Impossible de supprimer la recipe : ID de recipe manquant", "deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression", "deletedSuccessfully": "Recipe supprimée avec succès", diff --git a/locales/he.json b/locales/he.json index 33b5bb9f..f672b1b4 100644 --- a/locales/he.json +++ b/locales/he.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "מתכוני LoRA", + "actions": { + "sendCheckpoint": "שלח ל-ComfyUI" + }, "controls": { "import": { "action": "ייבא", @@ -1254,6 +1257,9 @@ "cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון", "sendFailed": "שליחת המתכון ל-workflow נכשלה", "sendError": "שגיאה בשליחת המתכון ל-workflow", + "missingCheckpointPath": "נתיב ה-checkpoint אינו זמין", + "missingCheckpointInfo": "חסרים פרטי checkpoint", + "downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}", "cannotDelete": "לא ניתן למחוק מתכון: חסר מזהה מתכון", "deleteConfirmationError": "שגיאה בהצגת אישור המחיקה", "deletedSuccessfully": "המתכון נמחק בהצלחה", diff --git a/locales/ja.json b/locales/ja.json index 178ca774..c592fc9c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "LoRAレシピ", + "actions": { + "sendCheckpoint": "ComfyUIへ送信" + }, "controls": { "import": { "action": "インポート", @@ -1254,6 +1257,9 @@ "cannotSend": "レシピを送信できません:レシピIDがありません", "sendFailed": "レシピのワークフローへの送信に失敗しました", "sendError": "レシピのワークフロー送信エラー", + "missingCheckpointPath": "チェックポイントのパスがありません", + "missingCheckpointInfo": "チェックポイント情報が不足しています", + "downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}", "cannotDelete": "レシピを削除できません:レシピIDがありません", "deleteConfirmationError": "削除確認の表示中にエラーが発生しました", "deletedSuccessfully": "レシピが正常に削除されました", diff --git a/locales/ko.json b/locales/ko.json index 394c2bed..1baee1b2 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "LoRA 레시피", + "actions": { + "sendCheckpoint": "ComfyUI로 보내기" + }, "controls": { "import": { "action": "가져오기", @@ -1254,6 +1257,9 @@ "cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락", "sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다", "sendError": "레시피를 워크플로로 전송하는 중 오류", + "missingCheckpointPath": "체크포인트 경로를 사용할 수 없습니다", + "missingCheckpointInfo": "체크포인트 정보가 부족합니다", + "downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}", "cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락", "deleteConfirmationError": "삭제 확인 표시 오류", "deletedSuccessfully": "레시피가 성공적으로 삭제되었습니다", diff --git a/locales/ru.json b/locales/ru.json index 6c3e1587..b21e69dd 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "Рецепты LoRA", + "actions": { + "sendCheckpoint": "Отправить в ComfyUI" + }, "controls": { "import": { "action": "Импортировать", @@ -1254,6 +1257,9 @@ "cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта", "sendFailed": "Не удалось отправить рецепт в workflow", "sendError": "Ошибка отправки рецепта в workflow", + "missingCheckpointPath": "Путь к чекпойнту недоступен", + "missingCheckpointInfo": "Отсутствуют данные о чекпойнте", + "downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}", "cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта", "deleteConfirmationError": "Ошибка отображения подтверждения удаления", "deletedSuccessfully": "Рецепт успешно удален", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 1bc61118..ddbf523d 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "LoRA 配方", + "actions": { + "sendCheckpoint": "发送到 ComfyUI" + }, "controls": { "import": { "action": "导入", @@ -1254,6 +1257,9 @@ "cannotSend": "无法发送配方:缺少配方 ID", "sendFailed": "发送配方到工作流失败", "sendError": "发送配方到工作流出错", + "missingCheckpointPath": "缺少检查点路径", + "missingCheckpointInfo": "缺少检查点信息", + "downloadCheckpointFailed": "下载检查点失败:{message}", "cannotDelete": "无法删除配方:缺少配方 ID", "deleteConfirmationError": "显示删除确认出错", "deletedSuccessfully": "配方删除成功", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a524c3e2..dcb4a3f4 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -528,6 +528,9 @@ }, "recipes": { "title": "LoRA 配方", + "actions": { + "sendCheckpoint": "傳送到 ComfyUI" + }, "controls": { "import": { "action": "匯入", @@ -1254,6 +1257,9 @@ "cannotSend": "無法傳送配方:缺少配方 ID", "sendFailed": "傳送配方到工作流失敗", "sendError": "傳送配方到工作流錯誤", + "missingCheckpointPath": "缺少檢查點路徑", + "missingCheckpointInfo": "缺少檢查點資訊", + "downloadCheckpointFailed": "下載檢查點失敗:{message}", "cannotDelete": "無法刪除配方:缺少配方 ID", "deleteConfirmationError": "顯示刪除確認時發生錯誤", "deletedSuccessfully": "配方已成功刪除", diff --git a/py/routes/checkpoint_routes.py b/py/routes/checkpoint_routes.py index 16ebd338..e22ab2a3 100644 --- a/py/routes/checkpoint_routes.py +++ b/py/routes/checkpoint_routes.py @@ -1,4 +1,5 @@ import logging +from typing import Dict from aiohttp import web from .base_model_routes import BaseModelRoutes @@ -51,6 +52,19 @@ class CheckpointRoutes(BaseModelRoutes): def _get_expected_model_types(self) -> str: """Get expected model types string for error messages""" return "Checkpoint" + + def _parse_specific_params(self, request: web.Request) -> Dict: + """Parse Checkpoint-specific parameters""" + params: Dict = {} + + if 'checkpoint_hash' in request.query: + params['hash_filters'] = {'single_hash': request.query['checkpoint_hash'].lower()} + elif 'checkpoint_hashes' in request.query: + params['hash_filters'] = { + 'multiple_hashes': [h.lower() for h in request.query['checkpoint_hashes'].split(',')] + } + + return params async def get_checkpoint_info(self, request: web.Request) -> web.Response: """Get detailed information for a specific checkpoint by name""" diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index edcec091..f561abe8 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -635,7 +635,7 @@ } .recipe-lora-item.checkpoint-item { - cursor: default; + cursor: pointer; padding-top: 8px; padding-bottom: 8px; align-items: center; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index ec0b6289..e651b4dc 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -877,6 +877,21 @@ export class BaseModelApiClient { console.error('Error parsing lora hashes from session storage:', error); } } + } else if (this.modelType === 'checkpoints') { + const filterCheckpointHash = getSessionItem('recipe_to_checkpoint_filterHash'); + const filterCheckpointHashes = getSessionItem('recipe_to_checkpoint_filterHashes'); + + if (filterCheckpointHash) { + params.append('checkpoint_hash', filterCheckpointHash); + } else if (filterCheckpointHashes) { + try { + if (Array.isArray(filterCheckpointHashes) && filterCheckpointHashes.length > 0) { + params.append('checkpoint_hashes', filterCheckpointHashes.join(',')); + } + } catch (error) { + console.error('Error parsing checkpoint hashes from session storage:', error); + } + } } } diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 299a2a11..7e909883 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -351,6 +351,7 @@ class RecipeModal { if (recipe.checkpoint && typeof recipe.checkpoint === 'object') { checkpointContainer.innerHTML = this.renderCheckpoint(recipe.checkpoint); this.setupCheckpointActions(checkpointContainer, recipe.checkpoint); + this.setupCheckpointNavigation(checkpointContainer, recipe.checkpoint); } } @@ -1150,6 +1151,15 @@ class RecipeModal { } } + setupCheckpointNavigation(container, checkpoint) { + const checkpointItem = container.querySelector('.checkpoint-item'); + if (!checkpointItem) return; + + checkpointItem.addEventListener('click', () => { + this.navigateToCheckpointPage(checkpoint); + }); + } + canDownloadCheckpoint(checkpoint) { if (!checkpoint) return false; const modelId = checkpoint.modelId || checkpoint.modelID || checkpoint.model_id; @@ -1238,8 +1248,42 @@ class RecipeModal { } } + navigateToCheckpointPage(checkpoint) { + const checkpointHash = this._getCheckpointHash(checkpoint); + + if (!checkpointHash) { + showToast('toast.recipes.missingCheckpointInfo', {}, 'error'); + return; + } + + modalManager.closeModal('recipeModal'); + + removeSessionItem('recipe_to_checkpoint_filterHash'); + removeSessionItem('recipe_to_checkpoint_filterHashes'); + removeSessionItem('filterCheckpointRecipeName'); + + setSessionItem('recipe_to_checkpoint_filterHash', checkpointHash.toLowerCase()); + if (this.currentRecipe?.title) { + setSessionItem('filterCheckpointRecipeName', this.currentRecipe.title); + } + + window.location.href = '/checkpoints'; + } + + _getCheckpointHash(checkpoint) { + if (!checkpoint) return ''; + const hash = + checkpoint.hash || + checkpoint.sha256 || + checkpoint.sha256_hash || + checkpoint.sha256Hash || + checkpoint.SHA256; + return hash ? hash.toString() : ''; + } + // New method to navigate to the LoRAs page navigateToLorasPage(specificLoraIndex = null) { + debugger; // Close the current modal modalManager.closeModal('recipeModal'); @@ -1278,7 +1322,7 @@ class RecipeModal { // New method to make LoRA items clickable setupLoraItemsClickable() { - const loraItems = document.querySelectorAll('.recipe-lora-item'); + const loraItems = document.querySelectorAll('.recipe-lora-item:not(.checkpoint-item)'); loraItems.forEach(item => { // Get the lora index from the data attribute const loraIndex = parseInt(item.dataset.loraIndex); diff --git a/static/js/components/controls/CheckpointsControls.js b/static/js/components/controls/CheckpointsControls.js index fa1e48e2..079037ff 100644 --- a/static/js/components/controls/CheckpointsControls.js +++ b/static/js/components/controls/CheckpointsControls.js @@ -1,7 +1,7 @@ // CheckpointsControls.js - Specific implementation for the Checkpoints page import { PageControls } from './PageControls.js'; import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'; -import { showToast } from '../../utils/uiHelpers.js'; +import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { downloadManager } from '../../managers/DownloadManager.js'; /** @@ -14,6 +14,9 @@ export class CheckpointsControls extends PageControls { // Register API methods specific to the Checkpoints page this.registerCheckpointsAPI(); + + // Check for custom filters (e.g., from recipe navigation) + this.checkCustomFilters(); } /** @@ -52,14 +55,65 @@ export class CheckpointsControls extends PageControls { } }, - // No clearCustomFilter implementation is needed for checkpoints - // as custom filters are currently only used for LoRAs clearCustomFilter: async () => { - showToast('toast.filters.noCustomFilterToClear', {}, 'info'); + await this.clearCustomFilter(); } }; // Register the API this.registerAPI(checkpointsAPI); } -} \ No newline at end of file + + /** + * Check for custom filters sent from other pages (e.g., recipe modal) + */ + checkCustomFilters() { + const filterCheckpointHash = getSessionItem('recipe_to_checkpoint_filterHash'); + const filterRecipeName = getSessionItem('filterCheckpointRecipeName'); + + if (filterCheckpointHash && filterRecipeName) { + const indicator = document.getElementById('customFilterIndicator'); + const filterText = indicator?.querySelector('.customFilterText'); + + if (indicator && filterText) { + indicator.classList.remove('hidden'); + + const displayText = `Viewing checkpoint from: ${filterRecipeName}`; + filterText.textContent = this._truncateText(displayText, 30); + filterText.setAttribute('title', displayText); + + const filterElement = indicator.querySelector('.filter-active'); + if (filterElement) { + filterElement.classList.add('animate'); + setTimeout(() => filterElement.classList.remove('animate'), 600); + } + } + } + } + + /** + * Clear checkpoint custom filter and reload + */ + async clearCustomFilter() { + removeSessionItem('recipe_to_checkpoint_filterHash'); + removeSessionItem('recipe_to_checkpoint_filterHashes'); + removeSessionItem('filterCheckpointRecipeName'); + + const indicator = document.getElementById('customFilterIndicator'); + if (indicator) { + indicator.classList.add('hidden'); + } + + await resetAndReload(); + } + + /** + * Helper to truncate text with ellipsis + * @param {string} text + * @param {number} maxLength + * @returns {string} + */ + _truncateText(text, maxLength) { + return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text; + } +} diff --git a/tests/frontend/components/pageControls.filtering.test.js b/tests/frontend/components/pageControls.filtering.test.js index 3954a394..678cea51 100644 --- a/tests/frontend/components/pageControls.filtering.test.js +++ b/tests/frontend/components/pageControls.filtering.test.js @@ -62,6 +62,8 @@ vi.mock('../../../static/js/utils/updateCheckHelpers.js', () => ({ beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + localStorage.clear(); + sessionStorage.clear(); loadMoreWithVirtualScrollMock.mockResolvedValue(undefined); refreshModelsMock.mockResolvedValue(undefined); @@ -163,6 +165,8 @@ function renderControlsDom(pageKey) { @@ -518,6 +522,35 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => { expect(stateModule.getCurrentPageState().sortBy).toBe('date:desc'); }); + it('shows checkpoint custom filter indicator from recipes and clears it', async () => { + renderControlsDom('checkpoints'); + const stateModule = await import('../../../static/js/state/index.js'); + stateModule.initPageState('checkpoints'); + + sessionStorage.setItem('lora_manager_recipe_to_checkpoint_filterHash', 'abc123'); + sessionStorage.setItem('lora_manager_filterCheckpointRecipeName', 'Flux Recipe With Long Name'); + + const { CheckpointsControls } = await import('../../../static/js/components/controls/CheckpointsControls.js'); + + const controls = new CheckpointsControls(); + + const indicator = document.getElementById('customFilterIndicator'); + expect(indicator.classList.contains('hidden')).toBe(false); + + const filterText = indicator.querySelector('.customFilterText'); + expect(filterText.textContent.startsWith('Viewing checkpoint from:')).toBe(true); + expect(filterText.getAttribute('title')).toBe('Viewing checkpoint from: Flux Recipe With Long Name'); + + resetAndReloadMock.mockClear(); + + await controls.clearCustomFilter(); + + expect(sessionStorage.getItem('lora_manager_recipe_to_checkpoint_filterHash')).toBeNull(); + expect(sessionStorage.getItem('lora_manager_filterCheckpointRecipeName')).toBeNull(); + expect(indicator.classList.contains('hidden')).toBe(true); + expect(resetAndReloadMock).toHaveBeenCalled(); + }); + it('updates duplicate badge after refresh and toggles duplicate mode from controls', async () => { renderControlsDom('checkpoints'); const stateModule = await import('../../../static/js/state/index.js');