diff --git a/locales/de.json b/locales/de.json index 312ea288..21d973a4 100644 --- a/locales/de.json +++ b/locales/de.json @@ -222,6 +222,7 @@ "modelname": "Modellname", "tags": "Tags", "creator": "Ersteller", + "hash": "Hash", "title": "Rezept-Titel", "loraName": "LoRA-Dateiname", "loraModel": "LoRA-Modellname", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "Ersteller-Profil anzeigen", "openFileLocation": "Dateispeicherort öffnen", "sendToWorkflow": "An ComfyUI senden", - "sendToWorkflowText": "An ComfyUI senden" + "sendToWorkflowText": "An ComfyUI senden", + "copyHash": "Hash kopieren" }, "openFileLocation": { "success": "Dateispeicherort erfolgreich geöffnet", @@ -1450,6 +1452,7 @@ "location": "Speicherort", "baseModel": "Basis-Modell", "size": "Größe", + "hashes": "Hashes", "unknown": "Unbekannt", "usageTips": "Nutzungstipps", "additionalNotes": "Zusätzliche Notizen", diff --git a/locales/en.json b/locales/en.json index dc728f7b..e4b682f7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -222,6 +222,7 @@ "modelname": "Model Name", "tags": "Tags", "creator": "Creator", + "hash": "Hash", "title": "Recipe Title", "loraName": "LoRA Filename", "loraModel": "LoRA Model Name", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "View Creator Profile", "openFileLocation": "Open File Location", "sendToWorkflow": "Send to ComfyUI", - "sendToWorkflowText": "Send to ComfyUI" + "sendToWorkflowText": "Send to ComfyUI", + "copyHash": "Copy hash" }, "openFileLocation": { "success": "File location opened successfully", @@ -1450,6 +1452,7 @@ "location": "Location", "baseModel": "Base Model", "size": "Size", + "hashes": "Hashes", "unknown": "Unknown", "usageTips": "Usage Tips", "additionalNotes": "Additional Notes", diff --git a/locales/es.json b/locales/es.json index 341aa9e1..a93dacec 100644 --- a/locales/es.json +++ b/locales/es.json @@ -222,6 +222,7 @@ "modelname": "Nombre del modelo", "tags": "Etiquetas", "creator": "Creador", + "hash": "Hash", "title": "Título de la receta", "loraName": "Nombre de archivo LoRA", "loraModel": "Nombre del modelo LoRA", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "Ver perfil del creador", "openFileLocation": "Abrir ubicación del archivo", "sendToWorkflow": "Enviar a ComfyUI", - "sendToWorkflowText": "Enviar a ComfyUI" + "sendToWorkflowText": "Enviar a ComfyUI", + "copyHash": "Copiar hash" }, "openFileLocation": { "success": "Ubicación del archivo abierta exitosamente", @@ -1450,6 +1452,7 @@ "location": "Ubicación", "baseModel": "Modelo base", "size": "Tamaño", + "hashes": "Hashes", "unknown": "Desconocido", "usageTips": "Consejos de uso", "additionalNotes": "Notas adicionales", diff --git a/locales/fr.json b/locales/fr.json index 6c8fa3e3..6285939e 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -222,6 +222,7 @@ "modelname": "Nom du modèle", "tags": "Tags", "creator": "Créateur", + "hash": "Hash", "title": "Titre de la recipe", "loraName": "Nom de fichier LoRA", "loraModel": "Nom du modèle LoRA", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "Voir le profil du créateur", "openFileLocation": "Ouvrir l'emplacement du fichier", "sendToWorkflow": "Envoyer vers ComfyUI", - "sendToWorkflowText": "Envoyer vers ComfyUI" + "sendToWorkflowText": "Envoyer vers ComfyUI", + "copyHash": "Copier le hash" }, "openFileLocation": { "success": "Emplacement du fichier ouvert avec succès", @@ -1450,6 +1452,7 @@ "location": "Emplacement", "baseModel": "Modèle de base", "size": "Taille", + "hashes": "Hashes", "unknown": "Inconnu", "usageTips": "Conseils d'utilisation", "additionalNotes": "Notes supplémentaires", diff --git a/locales/he.json b/locales/he.json index f0565a56..2ecb064a 100644 --- a/locales/he.json +++ b/locales/he.json @@ -222,6 +222,7 @@ "modelname": "שם מודל", "tags": "תגיות", "creator": "יוצר", + "hash": "האש", "title": "כותרת מתכון", "loraName": "שם קובץ LoRA", "loraModel": "שם מודל LoRA", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "הצג פרופיל יוצר", "openFileLocation": "פתח מיקום קובץ", "sendToWorkflow": "שלח ל-ComfyUI", - "sendToWorkflowText": "שלח ל-ComfyUI" + "sendToWorkflowText": "שלח ל-ComfyUI", + "copyHash": "העתק האש" }, "openFileLocation": { "success": "מיקום הקובץ נפתח בהצלחה", @@ -1450,6 +1452,7 @@ "location": "מיקום", "baseModel": "מודל בסיס", "size": "גודל", + "hashes": "האשים", "unknown": "לא ידוע", "usageTips": "טיפים לשימוש", "additionalNotes": "הערות נוספות", diff --git a/locales/ja.json b/locales/ja.json index 990422d1..e905ed5e 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -222,6 +222,7 @@ "modelname": "モデル名", "tags": "タグ", "creator": "作成者", + "hash": "ハッシュ", "title": "レシピタイトル", "loraName": "LoRAファイル名", "loraModel": "LoRAモデル名", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "作成者プロフィールを表示", "openFileLocation": "ファイルの場所を開く", "sendToWorkflow": "ComfyUI に送信", - "sendToWorkflowText": "ComfyUI に送信" + "sendToWorkflowText": "ComfyUI に送信", + "copyHash": "ハッシュをコピー" }, "openFileLocation": { "success": "ファイルの場所を正常に開きました", @@ -1450,6 +1452,7 @@ "location": "場所", "baseModel": "ベースモデル", "size": "サイズ", + "hashes": "ハッシュ", "unknown": "不明", "usageTips": "使用のヒント", "additionalNotes": "追加メモ", diff --git a/locales/ko.json b/locales/ko.json index e4e33712..91fb81d9 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -222,6 +222,7 @@ "modelname": "모델명", "tags": "태그", "creator": "제작자", + "hash": "해시", "title": "레시피 제목", "loraName": "LoRA 파일명", "loraModel": "LoRA 모델명", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "제작자 프로필 보기", "openFileLocation": "파일 위치 열기", "sendToWorkflow": "ComfyUI로 보내기", - "sendToWorkflowText": "ComfyUI로 보내기" + "sendToWorkflowText": "ComfyUI로 보내기", + "copyHash": "해시 복사" }, "openFileLocation": { "success": "파일 위치가 성공적으로 열렸습니다", @@ -1450,6 +1452,7 @@ "location": "위치", "baseModel": "베이스 모델", "size": "크기", + "hashes": "해시", "unknown": "알 수 없음", "usageTips": "사용 팁", "additionalNotes": "추가 메모", diff --git a/locales/ru.json b/locales/ru.json index 2929a885..95f7bbdb 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -222,6 +222,7 @@ "modelname": "Название модели", "tags": "Теги", "creator": "Автор", + "hash": "Хэш", "title": "Название рецепта", "loraName": "Имя файла LoRA", "loraModel": "Название модели LoRA", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "Посмотреть профиль создателя", "openFileLocation": "Открыть расположение файла", "sendToWorkflow": "Отправить в ComfyUI", - "sendToWorkflowText": "Отправить в ComfyUI" + "sendToWorkflowText": "Отправить в ComfyUI", + "copyHash": "Копировать хэш" }, "openFileLocation": { "success": "Расположение файла успешно открыто", @@ -1450,6 +1452,7 @@ "location": "Расположение", "baseModel": "Базовая модель", "size": "Размер", + "hashes": "Хэши", "unknown": "Неизвестно", "usageTips": "Советы по использованию", "additionalNotes": "Дополнительные заметки", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 3c916759..0ef30301 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -222,6 +222,7 @@ "modelname": "模型名称", "tags": "标签", "creator": "创作者", + "hash": "哈希", "title": "配方标题", "loraName": "LoRA 文件名", "loraModel": "LoRA 模型名称", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "查看创作者主页", "openFileLocation": "打开文件位置", "sendToWorkflow": "发送到 ComfyUI", - "sendToWorkflowText": "发送到 ComfyUI" + "sendToWorkflowText": "发送到 ComfyUI", + "copyHash": "复制哈希值" }, "openFileLocation": { "success": "文件位置已成功打开", @@ -1450,6 +1452,7 @@ "location": "位置", "baseModel": "基础模型", "size": "大小", + "hashes": "哈希值", "unknown": "未知", "usageTips": "使用提示", "additionalNotes": "附加备注", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 8e274a4c..c291ad0a 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -222,6 +222,7 @@ "modelname": "模型名稱", "tags": "標籤", "creator": "創作者", + "hash": "雜湊", "title": "配方標題", "loraName": "LoRA 檔案名稱", "loraModel": "LoRA 模型名稱", @@ -1433,7 +1434,8 @@ "viewCreatorProfile": "查看創作者個人檔案", "openFileLocation": "開啟檔案位置", "sendToWorkflow": "傳送到 ComfyUI", - "sendToWorkflowText": "傳送到 ComfyUI" + "sendToWorkflowText": "傳送到 ComfyUI", + "copyHash": "複製雜湊值" }, "openFileLocation": { "success": "檔案位置已成功開啟", @@ -1450,6 +1452,7 @@ "location": "位置", "baseModel": "基礎模型", "size": "大小", + "hashes": "雜湊值", "unknown": "未知", "usageTips": "使用提示", "additionalNotes": "附加備註", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 6254ebfd..3fc2c0b6 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -364,6 +364,7 @@ class ModelListingHandler: == "true", "tags": request.query.get("search_tags", "false").lower() == "true", "creator": request.query.get("search_creator", "false").lower() == "true", + "hash": request.query.get("search_hash", "false").lower() == "true", "recursive": request.query.get("recursive", "true").lower() == "true", } diff --git a/py/services/checkpoint_service.py b/py/services/checkpoint_service.py index 1c6922e5..73469d3e 100644 --- a/py/services/checkpoint_service.py +++ b/py/services/checkpoint_service.py @@ -51,6 +51,7 @@ class CheckpointService(BaseModelService): "base_model": model_data.get("base_model", ""), "folder": folder, "sha256": model_data.get("sha256", ""), + "autov3": model_data.get("autov3"), "file_path": file_path.replace(os.sep, "/"), "file_size": model_data.get("size", 0), "modified": model_data.get("modified", ""), diff --git a/py/services/embedding_service.py b/py/services/embedding_service.py index 7913f581..8e03c493 100644 --- a/py/services/embedding_service.py +++ b/py/services/embedding_service.py @@ -51,6 +51,7 @@ class EmbeddingService(BaseModelService): "base_model": model_data.get("base_model", ""), "folder": folder, "sha256": model_data.get("sha256", ""), + "autov3": model_data.get("autov3"), "file_path": file_path.replace(os.sep, "/"), "file_size": model_data.get("size", 0), "modified": model_data.get("modified", ""), diff --git a/py/services/lora_service.py b/py/services/lora_service.py index fa4863b7..6b7789ca 100644 --- a/py/services/lora_service.py +++ b/py/services/lora_service.py @@ -58,6 +58,7 @@ class LoraService(BaseModelService): "base_model": model_data.get("base_model", ""), "folder": folder, "sha256": model_data.get("sha256", ""), + "autov3": model_data.get("autov3"), "file_path": file_path.replace(os.sep, "/"), "file_size": model_data.get("size", 0), "modified": model_data.get("modified", ""), diff --git a/py/services/model_query.py b/py/services/model_query.py index b5ba2daa..29d5c544 100644 --- a/py/services/model_query.py +++ b/py/services/model_query.py @@ -432,6 +432,7 @@ class SearchStrategy: "tags": False, "recursive": True, "creator": False, + "hash": False, } def __init__( @@ -494,8 +495,28 @@ class SearchStrategy: results.append(item) continue + # Hash search is always exact (never fuzzy): match the full + # sha256, its autov2 prefix (first 10 chars), or the autov3 hash. + if options.get("hash", False): + hash_query = search_lower.strip() + if hash_query and self._matches_hash(item, hash_query): + results.append(item) + continue + return results + def _matches_hash(self, item: Dict[str, Any], hash_query: str) -> bool: + """Exact-match the normalized query against the item's known hashes.""" + sha256 = item.get("sha256") + sha256_lower = sha256.lower() if isinstance(sha256, str) else "" + if sha256_lower and hash_query in (sha256_lower, sha256_lower[:10]): + return True + # autov3 is None when unchecked and "" when checked but unavailable + autov3 = item.get("autov3") + if isinstance(autov3, str) and autov3 and hash_query == autov3.lower(): + return True + return False + def _matches( self, candidate: str, search_term: str, search_lower: str, fuzzy: bool ) -> bool: diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css index a9075955..fd095033 100644 --- a/static/css/components/lora-modal/lora-modal.css +++ b/static/css/components/lora-modal/lora-modal.css @@ -216,6 +216,62 @@ justify-content: space-between; } +/* Hashes footnote — borderless full-width muted line; reads as a footnote + to the file info grid rather than a peer field */ +.hash-footnote { + grid-column: 1 / -1; + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 8px; + padding: 0 var(--space-1); + color: var(--text-color); +} + +.hash-footnote .hash-entry { + display: inline-flex; + align-items: baseline; + gap: 6px; +} + +.hash-footnote .hash-kind { + font-size: 0.7em; + opacity: 0.5; + text-transform: uppercase; + letter-spacing: 0.03em; + flex-shrink: 0; +} + +.hash-footnote .model-hash-value { + font-family: monospace; + font-size: 0.8em; + opacity: 0.6; + white-space: nowrap; +} + +.hash-footnote .hash-sep { + opacity: 0.3; + font-size: 0.8em; +} + +.hash-footnote .hash-copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 2px; + border: none; + background: none; + color: var(--text-color); + opacity: 0.35; + font-size: 0.7em; + cursor: pointer; + flex-shrink: 0; +} + +.hash-footnote .hash-copy-btn:hover { + opacity: 0.9; +} + /* Toggle button — icon only, inline with the label */ .notes-toggle-btn { display: none; /* shown by JS when content exceeds threshold */ diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 6fd07250..3a5bc3ea 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1337,6 +1337,9 @@ export class BaseModelApiClient { if (pageState.searchOptions.creator !== undefined) { params.append('search_creator', pageState.searchOptions.creator.toString()); } + if (pageState.searchOptions.hash !== undefined) { + params.append('search_hash', pageState.searchOptions.hash.toString()); + } } } diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index f6252f64..85cb55b6 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -316,6 +316,7 @@ async function showModelModalFromCard(card, modelType) { // Create model metadata object const modelMeta = { sha256: card.dataset.sha256, + autov3: card.dataset.autov3 || '', preview_url: getCardPreviewUrl(card), file_path: card.dataset.filepath, model_name: card.dataset.name, @@ -406,6 +407,7 @@ function showExampleAccessModal(card, modelType) { // Get the model data from card dataset (works for both lora and checkpoint) const modelMeta = { sha256: card.dataset.sha256, + autov3: card.dataset.autov3 || '', preview_url: getCardPreviewUrl(card), file_path: card.dataset.filepath, model_name: card.dataset.name, @@ -460,6 +462,7 @@ export function createModelCard(model, modelType) { // below, which ignore internal card drags via MODEL_CARD_DRAG_MIME_TYPE. card.draggable = true; card.dataset.sha256 = model.sha256; + card.dataset.autov3 = model.autov3 || ''; card.dataset.filepath = model.file_path; card.dataset.name = model.model_name; card.dataset.file_name = model.file_name; diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index dee5568d..a6918b54 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -1,4 +1,4 @@ -import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js'; +import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax, copyToClipboard } from '../../utils/uiHelpers.js'; import { modalManager } from '../../managers/ModalManager.js'; import { MODEL_TYPES } from '../../api/apiConfig.js'; import { @@ -351,6 +351,39 @@ export async function showModelModal(model, modelType) { }; const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); + // De-emphasized hash display: a borderless full-width footnote line below + // the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in + // full (12 chars); the full value is copied via data-hash. + const modelSha256 = modelWithFullData.sha256 || ''; + const modelAutov3 = modelWithFullData.autov3 || ''; + const truncatedSha256 = modelSha256.length > 16 + ? `${modelSha256.slice(0, 10)}\u2026${modelSha256.slice(-6)}` + : modelSha256; + const copyHashTitle = translate('modals.model.actions.copyHash', {}, 'Copy hash'); + const hashEntries = []; + if (modelSha256) { + hashEntries.push(` + + SHA256 + ${escapeHtml(truncatedSha256)} + + `); + } + if (modelAutov3) { + hashEntries.push(` + + AutoV3 + ${escapeHtml(modelAutov3)} + + `); + } + const hashesMarkup = modelSha256 && hashEntries.length ? ` +
${hashEntries.join('·')} +
` : ''; const useNewIcons = state.global.settings.use_new_license_icons !== false; const licenseIcons = useNewIcons ? renderNewLicenseIcons(modelWithFullData) @@ -613,6 +646,7 @@ export async function showModelModal(model, modelType) { ${formatFileSize(modelWithFullData.file_size)} + ${hashesMarkup} ${typeSpecificContent}
@@ -910,6 +944,11 @@ function setupEventHandlers(filePath, modelType) { case 'send-to-workflow': handleSendToWorkflow(target, modelType); break; + case 'copy-hash': + if (target.dataset.hash) { + copyToClipboard(target.dataset.hash, 'Hash copied to clipboard'); + } + break; } } diff --git a/static/js/i18n/index.js b/static/js/i18n/index.js index 351c66e9..0a3c0504 100644 --- a/static/js/i18n/index.js +++ b/static/js/i18n/index.js @@ -49,7 +49,10 @@ class I18nManager { } try { - const response = await fetch(`/locales/${normalizedLocale}.json`); + // 'no-cache' forces revalidation (cheap 304 via ETag) so locale + // edits are picked up on a plain reload instead of serving a + // stale cached copy. + const response = await fetch(`/locales/${normalizedLocale}.json`, { cache: 'no-cache' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } diff --git a/static/js/managers/SearchManager.js b/static/js/managers/SearchManager.js index 21fd7b08..b4612f20 100644 --- a/static/js/managers/SearchManager.js +++ b/static/js/managers/SearchManager.js @@ -304,6 +304,7 @@ export class SearchManager { pageState.searchOptions.modelname = options.modelname || false; pageState.searchOptions.tags = options.tags || false; pageState.searchOptions.creator = options.creator || false; + pageState.searchOptions.hash = options.hash || false; } } diff --git a/static/js/state/index.js b/static/js/state/index.js index 4d0659ad..d32f773a 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -103,6 +103,7 @@ export const state = { modelname: true, tags: false, creator: false, + hash: false, recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true), }, filters: { @@ -168,6 +169,7 @@ export const state = { filename: true, modelname: true, creator: false, + hash: false, recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true), }, filters: { @@ -207,6 +209,7 @@ export const state = { modelname: true, tags: false, creator: false, + hash: false, recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true), }, filters: { diff --git a/templates/components/header.html b/templates/components/header.html index 00a53da1..14049c02 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -192,17 +192,20 @@
{{ t('header.search.filters.modelname') }}
{{ t('header.search.filters.tags') }}
{{ t('header.search.filters.creator') }}
+
{{ t('header.search.filters.hash') }}
{% elif request.path == '/embeddings' %}
{{ t('header.search.filters.filename') }}
{{ t('header.search.filters.modelname') }}
{{ t('header.search.filters.tags') }}
{{ t('header.search.filters.creator') }}
+
{{ t('header.search.filters.hash') }}
{% else %}
{{ t('header.search.filters.filename') }}
{{ t('header.search.filters.modelname') }}
{{ t('header.search.filters.tags') }}
{{ t('header.search.filters.creator') }}
+
{{ t('header.search.filters.hash') }}
{% endif %}
diff --git a/tests/frontend/api/baseModelApi.searchParams.test.js b/tests/frontend/api/baseModelApi.searchParams.test.js new file mode 100644 index 00000000..f1977d1f --- /dev/null +++ b/tests/frontend/api/baseModelApi.searchParams.test.js @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from 'vitest'; + +const { + BASE_MODEL_API_MODULE, + STATE_MODULE, + UI_HELPERS_MODULE, + I18N_MODULE, + STORAGE_MODULE, + API_CONFIG_MODULE, + API_FACTORY_MODULE, + SIDEBAR_MANAGER_MODULE, +} = vi.hoisted(() => ({ + BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname, + API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname, + API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname, +})); + +vi.mock(STATE_MODULE, () => ({ + state: { + global: { settings: {} }, + }, + getCurrentPageState: vi.fn(() => ({})), +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), +})); + +vi.mock(I18N_MODULE, () => ({ + translate: vi.fn((key) => key), +})); + +vi.mock(STORAGE_MODULE, () => ({ + getStorageItem: vi.fn(), + getSessionItem: vi.fn(() => null), + removeSessionItem: vi.fn(), + saveMapToStorage: vi.fn(), +})); + +vi.mock(API_CONFIG_MODULE, () => ({ + getCompleteApiConfig: vi.fn(() => ({ + endpoints: {}, + config: { displayName: 'LoRA', singularName: 'LoRA', supportsLetterFilter: false }, + })), + getCurrentModelType: vi.fn(() => 'loras'), + isValidModelType: vi.fn(() => true), + DOWNLOAD_ENDPOINTS: {}, + HF_ENDPOINTS: {}, + WS_ENDPOINTS: {}, +})); + +vi.mock(API_FACTORY_MODULE, () => ({ + resetAndReload: vi.fn(), +})); + +vi.mock(SIDEBAR_MANAGER_MODULE, () => ({ + sidebarManager: { refresh: vi.fn() }, +})); + +async function createClient() { + const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE); + class TestClient extends BaseModelApiClient {} + return new TestClient('loras'); +} + +function makePageState(searchOptions) { + return { + viewMode: 'active', + activeFolder: null, + showFavoritesOnly: false, + showUpdateAvailableOnly: false, + filters: { search: 'abc123' }, + searchOptions: { + filename: true, + modelname: true, + tags: false, + creator: false, + recursive: true, + ...searchOptions, + }, + }; +} + +describe('BaseModelApiClient._buildQueryParams hash search option', () => { + it('appends search_hash=true when the hash option is enabled', async () => { + const client = await createClient(); + const params = client._buildQueryParams({}, makePageState({ hash: true })); + + expect(params.get('search_hash')).toBe('true'); + expect(params.get('search')).toBe('abc123'); + }); + + it('appends search_hash=false when the hash option is disabled', async () => { + const client = await createClient(); + const params = client._buildQueryParams({}, makePageState({ hash: false })); + + expect(params.get('search_hash')).toBe('false'); + }); + + it('omits search_hash when the option is absent (backend defaults to false)', async () => { + const client = await createClient(); + const params = client._buildQueryParams({}, makePageState({})); + + expect(params.get('search_hash')).toBeNull(); + }); + + it('does not send search_hash without an active search term', async () => { + const client = await createClient(); + const pageState = makePageState({ hash: true }); + pageState.filters.search = ''; + const params = client._buildQueryParams({}, pageState); + + expect(params.get('search_hash')).toBeNull(); + }); +}); diff --git a/tests/frontend/components/modelModal.hashes.test.js b/tests/frontend/components/modelModal.hashes.test.js new file mode 100644 index 00000000..cb4ce66b --- /dev/null +++ b/tests/frontend/components/modelModal.hashes.test.js @@ -0,0 +1,182 @@ +import { describe, it, beforeEach, expect, vi } from 'vitest'; + +const { + MODAL_MODULE, + API_FACTORY, + UI_HELPERS_MODULE, + MODAL_MANAGER_MODULE, + SHOWCASE_MODULE, + MODEL_TAGS_MODULE, + UTILS_MODULE, + TRIGGER_WORDS_MODULE, + PRESET_TAGS_MODULE, + MODEL_VERSIONS_MODULE, + RECIPE_TAB_MODULE, + I18N_HELPERS_MODULE, +} = vi.hoisted(() => ({ + MODAL_MODULE: new URL('../../../static/js/components/shared/ModelModal.js', import.meta.url).pathname, + API_FACTORY: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname, + MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname, + UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname, + TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname, + PRESET_TAGS_MODULE: new URL('../../../static/js/components/shared/PresetTags.js', import.meta.url).pathname, + MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname, + RECIPE_TAB_MODULE: new URL('../../../static/js/components/shared/RecipeTab.js', import.meta.url).pathname, + I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), + openCivitai: vi.fn(), + copyToClipboard: vi.fn(), +})); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: vi.fn((id, html) => { + document.body.innerHTML = `
${html}
`; + }), + closeModal: vi.fn(), + }, +})); + +vi.mock(SHOWCASE_MODULE, () => ({ + scrollToTop: vi.fn(), + loadExampleImages: vi.fn(), +})); + +vi.mock(MODEL_TAGS_MODULE, () => ({ + setupTagEditMode: vi.fn(), +})); + +vi.mock(UTILS_MODULE, async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderCompactTags: vi.fn(() => ''), + setupTagTooltip: vi.fn(), + formatFileSize: vi.fn(() => '1 MB'), + }; +}); + +vi.mock(TRIGGER_WORDS_MODULE, () => ({ + renderTriggerWords: vi.fn(() => ''), + setupTriggerWordsEditMode: vi.fn(), +})); + +vi.mock(PRESET_TAGS_MODULE, () => ({ + parsePresets: vi.fn(() => ({})), + renderPresetTags: vi.fn(() => ''), +})); + +vi.mock(MODEL_VERSIONS_MODULE, () => ({ + initVersionsTab: vi.fn(() => ({ + load: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock(RECIPE_TAB_MODULE, () => ({ + loadRecipesForModel: vi.fn(), +})); + +vi.mock(I18N_HELPERS_MODULE, () => ({ + translate: vi.fn((_, __, fallback) => fallback || ''), +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings' + } +})); + +vi.mock(API_FACTORY, () => ({ + getModelApiClient: vi.fn(), +})); + +const SHA256 = 'abcdef1234567890' + 'f'.repeat(48); +const AUTOV3 = '0123456789ab'; + +function makeModel(overrides = {}) { + return { + model_name: 'Hash Model', + file_path: 'models/hash.safetensors', + file_name: 'hash.safetensors', + sha256: SHA256, + autov3: AUTOV3, + civitai: {}, + ...overrides, + }; +} + +describe('Model modal hash rendering', () => { + let getModelApiClient; + let copyToClipboard; + + beforeEach(async () => { + document.body.innerHTML = ''; + ({ getModelApiClient } = await import(API_FACTORY)); + ({ copyToClipboard } = await import(UI_HELPERS_MODULE)); + getModelApiClient.mockReset(); + copyToClipboard.mockReset(); + getModelApiClient.mockReturnValue({ + fetchModelMetadata: vi.fn().mockResolvedValue(null), + saveModelMetadata: vi.fn(), + }); + }); + + async function renderModal(model) { + const { showModelModal } = await import(MODAL_MODULE); + await showModelModal(model, 'loras'); + } + + it('renders sha256 middle-truncated with the full hash in title and copy button', async () => { + await renderModal(makeModel()); + + const hashItem = document.querySelector('.hash-footnote'); + expect(hashItem).not.toBeNull(); + + const value = hashItem.querySelector('.model-hash-value'); + expect(value.textContent).toBe(`${SHA256.slice(0, 10)}\u2026${SHA256.slice(-6)}`); + expect(value.getAttribute('title')).toBe(SHA256); + + const copyBtn = hashItem.querySelector('[data-action="copy-hash"]'); + expect(copyBtn.dataset.hash).toBe(SHA256); + }); + + it('renders autov3 in full', async () => { + await renderModal(makeModel()); + + const rows = document.querySelectorAll('.hash-footnote .hash-entry'); + expect(rows).toHaveLength(2); + expect(rows[1].querySelector('.model-hash-value').textContent).toBe(AUTOV3); + expect(rows[1].querySelector('[data-action="copy-hash"]').dataset.hash).toBe(AUTOV3); + }); + + it.each([null, undefined, ''])('hides the autov3 row when autov3 is %s', async (autov3) => { + await renderModal(makeModel({ autov3 })); + + const rows = document.querySelectorAll('.hash-footnote .hash-entry'); + expect(rows).toHaveLength(1); + expect(rows[0].querySelector('.hash-kind').textContent).toBe('SHA256'); + }); + + it('hides the hashes item entirely when sha256 is empty', async () => { + await renderModal(makeModel({ sha256: '', autov3: AUTOV3 })); + + expect(document.querySelector('.hash-footnote')).toBeNull(); + }); + + it('copies the full hash when the copy button is clicked', async () => { + await renderModal(makeModel()); + + const copyBtn = document.querySelector('.hash-footnote [data-action="copy-hash"]'); + copyBtn.click(); + + expect(copyToClipboard).toHaveBeenCalledWith(SHA256, expect.any(String)); + }); +}); diff --git a/tests/services/test_model_query_search.py b/tests/services/test_model_query_search.py new file mode 100644 index 00000000..89f0aa9a --- /dev/null +++ b/tests/services/test_model_query_search.py @@ -0,0 +1,158 @@ +"""Tests for SearchStrategy hash-based exact matching and autov3 passthrough.""" + +from unittest.mock import MagicMock + +import pytest + +from py.services.checkpoint_service import CheckpointService +from py.services.embedding_service import EmbeddingService +from py.services.lora_service import LoraService +from py.services.model_query import SearchStrategy + +SHA256 = "abcdef1234567890" + "f" * 48 # 64-char hex +AUTOV2 = SHA256[:10] +AUTOV3 = "0123456789ab" + +HASH_ONLY_OPTIONS = { + "filename": False, + "modelname": False, + "tags": False, + "creator": False, + "hash": True, +} + +HASH_OFF_OPTIONS = { + "filename": False, + "modelname": False, + "tags": False, + "creator": False, + "hash": False, +} + + +def make_item(**overrides): + item = { + "file_name": "model.safetensors", + "model_name": "Some Model", + "tags": [], + "sha256": SHA256, + "autov3": AUTOV3, + } + item.update(overrides) + return item + + +@pytest.fixture +def strategy(): + return SearchStrategy() + + +class TestSearchStrategyHash: + """Hash search matches exactly against sha256, autov2, and autov3.""" + + def test_full_sha256_matches(self, strategy): + items = [make_item(), make_item(file_name="other.safetensors", sha256="0" * 64)] + result = strategy.apply(items, SHA256, HASH_ONLY_OPTIONS) + assert [item["file_name"] for item in result] == ["model.safetensors"] + + def test_autov2_prefix_matches(self, strategy): + result = strategy.apply([make_item()], AUTOV2, HASH_ONLY_OPTIONS) + assert len(result) == 1 + + def test_autov3_matches(self, strategy): + result = strategy.apply([make_item()], AUTOV3, HASH_ONLY_OPTIONS) + assert len(result) == 1 + + def test_query_is_case_insensitive(self, strategy): + result = strategy.apply([make_item()], SHA256.upper(), HASH_ONLY_OPTIONS) + assert len(result) == 1 + result = strategy.apply([make_item()], AUTOV3.upper(), HASH_ONLY_OPTIONS) + assert len(result) == 1 + + def test_query_whitespace_is_stripped(self, strategy): + result = strategy.apply([make_item()], f" {AUTOV3} ", HASH_ONLY_OPTIONS) + assert len(result) == 1 + + def test_partial_hash_does_not_match(self, strategy): + # Exact semantics: a 5-char fragment is neither autov2 nor autov3 + result = strategy.apply([make_item()], SHA256[:5], HASH_ONLY_OPTIONS) + assert result == [] + + def test_autov3_none_is_skipped(self, strategy): + item = make_item(autov3=None) + assert strategy.apply([item], AUTOV3, HASH_ONLY_OPTIONS) == [] + # sha256 matching still works + assert len(strategy.apply([item], SHA256, HASH_ONLY_OPTIONS)) == 1 + + def test_autov3_empty_string_is_skipped(self, strategy): + item = make_item(autov3="") + assert strategy.apply([item], AUTOV3, HASH_ONLY_OPTIONS) == [] + + def test_hash_option_disabled(self, strategy): + assert strategy.apply([make_item()], SHA256, HASH_OFF_OPTIONS) == [] + assert strategy.apply([make_item()], AUTOV3, HASH_OFF_OPTIONS) == [] + + def test_fuzzy_mode_still_exact(self, strategy): + # Fuzzy matching must never apply to the hash field + result = strategy.apply([make_item()], AUTOV3, HASH_ONLY_OPTIONS, fuzzy=True) + assert len(result) == 1 + result = strategy.apply([make_item()], SHA256[:5], HASH_ONLY_OPTIONS, fuzzy=True) + assert result == [] + + def test_missing_sha256_does_not_match(self, strategy): + item = make_item(sha256="", autov3=None) + assert strategy.apply([item], SHA256, HASH_ONLY_OPTIONS) == [] + + +class TestFormatResponseAutov3: + """format_response should pass the autov3 field through unchanged.""" + + @pytest.fixture + def mock_scanner(self): + scanner = MagicMock() + scanner._hash_index = MagicMock() + return scanner + + def make_model_data(self, autov3): + return { + "model_name": "Test Model", + "file_name": "test_model", + "base_model": "SDXL", + "folder": "", + "sha256": SHA256, + "autov3": autov3, + "file_path": "/models/test_model.safetensors", + "size": 1000, + "modified": 1234567890.0, + "tags": [], + "from_civitai": True, + "civitai": {}, + } + + @pytest.mark.asyncio + @pytest.mark.parametrize("autov3", [AUTOV3, "", None]) + async def test_lora_format_response_autov3(self, mock_scanner, autov3): + service = LoraService(mock_scanner) + result = await service.format_response(self.make_model_data(autov3)) + assert result["autov3"] == autov3 + + @pytest.mark.asyncio + @pytest.mark.parametrize("autov3", [AUTOV3, "", None]) + async def test_checkpoint_format_response_autov3(self, mock_scanner, autov3): + service = CheckpointService(mock_scanner) + result = await service.format_response(self.make_model_data(autov3)) + assert result["autov3"] == autov3 + + @pytest.mark.asyncio + @pytest.mark.parametrize("autov3", [AUTOV3, "", None]) + async def test_embedding_format_response_autov3(self, mock_scanner, autov3): + service = EmbeddingService(mock_scanner) + result = await service.format_response(self.make_model_data(autov3)) + assert result["autov3"] == autov3 + + @pytest.mark.asyncio + async def test_autov3_defaults_to_none(self, mock_scanner): + data = self.make_model_data(AUTOV3) + del data["autov3"] + result = await LoraService(mock_scanner).format_response(data) + assert result["autov3"] is None