From f8aba393fee1ee57db16a19b7995e2a56eeb1411 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 3 Oct 2026 09:46:46 +0800 Subject: [PATCH] feat(models): show Civitai model/version ids in model modal, always-on hash/id search - Model modal hash footnote now shows Civitai model id and version id (right-aligned, quick-copy buttons); hidden for non-Civitai models - Hash/id exact search (sha256/autov2/autov3/civitai ids) is now always on: the search-options "hash" toggle is removed and the search_hash query param is silently ignored for API compatibility - Footnote render condition relaxed so autov3-only and id-only models still show the line - i18n: 4 new keys translated in all 9 locales; filters.hash key removed --- docs/i18n-translation-guidelines.md | 26 ++++ locales/de.json | 5 +- locales/en.json | 5 +- locales/es.json | 5 +- locales/fr.json | 5 +- locales/he.json | 5 +- locales/ja.json | 5 +- locales/ko.json | 5 +- locales/ru.json | 5 +- locales/zh-CN.json | 5 +- locales/zh-TW.json | 5 +- py/routes/handlers/model_handlers.py | 1 - py/services/model_query.py | 28 ++-- .../css/components/lora-modal/lora-modal.css | 9 ++ static/js/api/baseModelApi.js | 3 - static/js/components/shared/ModelModal.js | 49 ++++++- static/js/managers/SearchManager.js | 1 - static/js/state/index.js | 4 - templates/components/header.html | 3 - .../api/baseModelApi.searchParams.test.js | 120 ------------------ .../components/modelModal.hashes.test.js | 66 +++++++++- tests/services/test_model_query_search.py | 61 +++++++-- 22 files changed, 254 insertions(+), 167 deletions(-) delete mode 100644 tests/frontend/api/baseModelApi.searchParams.test.js diff --git a/docs/i18n-translation-guidelines.md b/docs/i18n-translation-guidelines.md index aced6bad..8ac194a0 100644 --- a/docs/i18n-translation-guidelines.md +++ b/docs/i18n-translation-guidelines.md @@ -120,6 +120,14 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL). > style. All 9 locales are translated (terminology in §2, "Download routing feature"), > so the "no remaining placeholders" claim holds again. +> **Status (2026-10, Civitai ids in model modal):** the model modal's hash footnote now +> shows the Civitai model id and version id (right-aligned, with copy buttons), adding +> 4 keys (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId`, +> `modals.model.actions.copyCivitaiId` / `.civitaiIdCopied`). The same pass removed the +> search-options "hash" toggle (`header.search.filters.hash`) because hash/id search is +> now always on. All 9 locales are translated (terminology in §2, "Civitai ids feature"), +> so the "no remaining placeholders" claim holds again. + --- ## 1. Hard rules (do not violate) @@ -586,6 +594,24 @@ directly (model-type names, R3). The tooltip quotes the `modals.download.useDefa label verbatim with each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」, ko `' '`, fr « … », de „ … “, es/ru/he «…»). +### Civitai ids feature (model/version id in the model modal) + +The model modal's hash footnote shows the Civitai **model id** and **version id** with +copy buttons (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId` labels, +`modals.model.actions.copyCivitaiId` tooltip, `.civitaiIdCopied` toast). **"ID" stays +Latin in every locale** (same precedent as `recipes.*.copyId`), and `Civitai` is the +brand (R3) — it is never translated or transliterated; the casing mirrors `en.json` +verbatim (R9). The copy/copied strings reuse each locale's existing clipboard patterns +(`modals.model.actions.copyHash` / `openFileLocation.copied`). + +| Term | Rendering | +|---|---| +| Model ID (label) | zh-CN 模型 ID · zh-TW 模型 ID · ja モデル ID · ko 모델 ID · fr ID du modèle · de Modell-ID · es ID del modelo · ru ID модели · he מזהה מודל | +| Version ID (label) | zh-CN 版本 ID · zh-TW 版本 ID · ja バージョン ID · ko 버전 ID · fr ID de version · de Versions-ID · es ID de versión · ru ID версии · he מזהה גרסה | + +Hebrew uses its established מזהה ("identifier") noun instead of Latin `ID` in these +labels, matching `recipes.*.copyId` (העתק מזהה מתכון). + --- ## 3. Cross-cutting confusion hot-spots (must-fix list) diff --git a/locales/de.json b/locales/de.json index 99b92321..27d813f7 100644 --- a/locales/de.json +++ b/locales/de.json @@ -251,7 +251,6 @@ "modelname": "Modellname", "tags": "Tags", "creator": "Ersteller", - "hash": "Hash", "title": "Rezept-Titel", "loraName": "LoRA-Dateiname", "loraModel": "LoRA-Modellname", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "An ComfyUI senden", "sendToWorkflowText": "An ComfyUI senden", "copyHash": "Hash kopieren", + "copyCivitaiId": "Civitai-ID kopieren", + "civitaiIdCopied": "Civitai-ID in die Zwischenablage kopiert", "deleteModelWithShortcut": "Modell löschen (Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "Basismodell", "size": "Größe", "hashes": "Hashes", + "civitaiModelId": "Modell-ID", + "civitaiVersionId": "Versions-ID", "unknown": "Unbekannt", "usageTips": "Nutzungstipps", "additionalNotes": "Zusätzliche Notizen", diff --git a/locales/en.json b/locales/en.json index 9b83bf13..19eb7b77 100644 --- a/locales/en.json +++ b/locales/en.json @@ -251,7 +251,6 @@ "modelname": "Model Name", "tags": "Tags", "creator": "Creator", - "hash": "Hash", "title": "Recipe Title", "loraName": "LoRA Filename", "loraModel": "LoRA Model Name", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "Send to ComfyUI", "sendToWorkflowText": "Send to ComfyUI", "copyHash": "Copy hash", + "copyCivitaiId": "Copy Civitai ID", + "civitaiIdCopied": "Civitai ID copied to clipboard", "deleteModelWithShortcut": "Delete model (Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "Base Model", "size": "Size", "hashes": "Hashes", + "civitaiModelId": "Model ID", + "civitaiVersionId": "Version ID", "unknown": "Unknown", "usageTips": "Usage Tips", "additionalNotes": "Additional Notes", diff --git a/locales/es.json b/locales/es.json index bfc1e6de..65f05b1f 100644 --- a/locales/es.json +++ b/locales/es.json @@ -251,7 +251,6 @@ "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", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "Enviar a ComfyUI", "sendToWorkflowText": "Enviar a ComfyUI", "copyHash": "Copiar hash", + "copyCivitaiId": "Copiar ID de Civitai", + "civitaiIdCopied": "ID de Civitai copiado al portapapeles", "deleteModelWithShortcut": "Eliminar modelo (Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "Modelo base", "size": "Tamaño", "hashes": "Hashes", + "civitaiModelId": "ID del modelo", + "civitaiVersionId": "ID de versión", "unknown": "Desconocido", "usageTips": "Consejos de uso", "additionalNotes": "Notas adicionales", diff --git a/locales/fr.json b/locales/fr.json index 4dbfb441..0174265a 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -251,7 +251,6 @@ "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", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "Envoyer vers ComfyUI", "sendToWorkflowText": "Envoyer vers ComfyUI", "copyHash": "Copier le hash", + "copyCivitaiId": "Copier l’ID Civitai", + "civitaiIdCopied": "ID Civitai copié dans le presse-papiers", "deleteModelWithShortcut": "Supprimer le modèle (Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "Modèle de base", "size": "Taille", "hashes": "Hashes", + "civitaiModelId": "ID du modèle", + "civitaiVersionId": "ID de version", "unknown": "Inconnu", "usageTips": "Conseils d'utilisation", "additionalNotes": "Notes supplémentaires", diff --git a/locales/he.json b/locales/he.json index 7ba591c0..d70d9980 100644 --- a/locales/he.json +++ b/locales/he.json @@ -251,7 +251,6 @@ "modelname": "שם מודל", "tags": "תגיות", "creator": "יוצר", - "hash": "hash", "title": "כותרת מתכון", "loraName": "שם קובץ LoRA", "loraModel": "שם מודל LoRA", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "שלח ל-ComfyUI", "sendToWorkflowText": "שלח ל-ComfyUI", "copyHash": "העתק hash", + "copyCivitaiId": "העתק מזהה Civitai", + "civitaiIdCopied": "מזהה Civitai הועתק ללוח העריכה", "deleteModelWithShortcut": "מחק מודל (Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "מודל בסיס", "size": "גודל", "hashes": "hashes", + "civitaiModelId": "מזהה מודל", + "civitaiVersionId": "מזהה גרסה", "unknown": "לא ידוע", "usageTips": "טיפים לשימוש", "additionalNotes": "הערות נוספות", diff --git a/locales/ja.json b/locales/ja.json index 521ae066..91a8b874 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -251,7 +251,6 @@ "modelname": "モデル名", "tags": "タグ", "creator": "作成者", - "hash": "ハッシュ", "title": "レシピタイトル", "loraName": "LoRAファイル名", "loraModel": "LoRAモデル名", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "ComfyUI に送信", "sendToWorkflowText": "ComfyUI に送信", "copyHash": "ハッシュをコピー", + "copyCivitaiId": "Civitai IDをコピー", + "civitaiIdCopied": "Civitai IDをクリップボードにコピーしました", "deleteModelWithShortcut": "モデルを削除(Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "ベースモデル", "size": "サイズ", "hashes": "ハッシュ", + "civitaiModelId": "モデル ID", + "civitaiVersionId": "バージョン ID", "unknown": "不明", "usageTips": "使用のヒント", "additionalNotes": "追加メモ", diff --git a/locales/ko.json b/locales/ko.json index fb197ae7..3c034f75 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -251,7 +251,6 @@ "modelname": "모델명", "tags": "태그", "creator": "제작자", - "hash": "해시", "title": "레시피 제목", "loraName": "LoRA 파일명", "loraModel": "LoRA 모델명", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "ComfyUI로 보내기", "sendToWorkflowText": "ComfyUI로 보내기", "copyHash": "해시 복사", + "copyCivitaiId": "Civitai ID 복사", + "civitaiIdCopied": "Civitai ID가 클립보드에 복사되었습니다", "deleteModelWithShortcut": "모델 삭제(Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "베이스 모델", "size": "크기", "hashes": "해시", + "civitaiModelId": "모델 ID", + "civitaiVersionId": "버전 ID", "unknown": "알 수 없음", "usageTips": "사용 팁", "additionalNotes": "추가 메모", diff --git a/locales/ru.json b/locales/ru.json index 353085ae..1a1a975c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -251,7 +251,6 @@ "modelname": "Название модели", "tags": "Теги", "creator": "Автор", - "hash": "Хэш", "title": "Название рецепта", "loraName": "Имя файла LoRA", "loraModel": "Название модели LoRA", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "Отправить в ComfyUI", "sendToWorkflowText": "Отправить в ComfyUI", "copyHash": "Копировать хеш", + "copyCivitaiId": "Копировать ID Civitai", + "civitaiIdCopied": "ID Civitai скопирован в буфер обмена", "deleteModelWithShortcut": "Удалить модель (Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "Базовая модель", "size": "Размер", "hashes": "Хэши", + "civitaiModelId": "ID модели", + "civitaiVersionId": "ID версии", "unknown": "Неизвестно", "usageTips": "Советы по использованию", "additionalNotes": "Дополнительные заметки", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 01d30be8..db9c25ba 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -251,7 +251,6 @@ "modelname": "模型名称", "tags": "标签", "creator": "创作者", - "hash": "哈希", "title": "配方标题", "loraName": "LoRA 文件名", "loraModel": "LoRA 模型名称", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "发送到 ComfyUI", "sendToWorkflowText": "发送到 ComfyUI", "copyHash": "复制哈希值", + "copyCivitaiId": "复制 Civitai ID", + "civitaiIdCopied": "Civitai ID 已复制到剪贴板", "deleteModelWithShortcut": "删除模型(Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "基础模型", "size": "大小", "hashes": "哈希值", + "civitaiModelId": "模型 ID", + "civitaiVersionId": "版本 ID", "unknown": "未知", "usageTips": "使用提示", "additionalNotes": "附加备注", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 4454e8db..f9689df6 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -251,7 +251,6 @@ "modelname": "模型名稱", "tags": "標籤", "creator": "創作者", - "hash": "雜湊", "title": "配方標題", "loraName": "LoRA 檔案名稱", "loraModel": "LoRA 模型名稱", @@ -1837,6 +1836,8 @@ "sendToWorkflow": "傳送到 ComfyUI", "sendToWorkflowText": "傳送到 ComfyUI", "copyHash": "複製雜湊值", + "copyCivitaiId": "複製 Civitai ID", + "civitaiIdCopied": "Civitai ID 已複製到剪貼簿", "deleteModelWithShortcut": "刪除模型(Del)" }, "openFileLocation": { @@ -1855,6 +1856,8 @@ "baseModel": "基礎模型", "size": "大小", "hashes": "雜湊值", + "civitaiModelId": "模型 ID", + "civitaiVersionId": "版本 ID", "unknown": "未知", "usageTips": "使用提示", "additionalNotes": "附加備註", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index a2287ef7..d2fe867b 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -387,7 +387,6 @@ 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/model_query.py b/py/services/model_query.py index 29d5c544..a15ea05e 100644 --- a/py/services/model_query.py +++ b/py/services/model_query.py @@ -432,7 +432,6 @@ class SearchStrategy: "tags": False, "recursive": True, "creator": False, - "hash": False, } def __init__( @@ -495,13 +494,14 @@ 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 + # Hash/id search is always exact (never fuzzy) and always on: it + # matches the full sha256, its autov2 prefix (first 10 chars), + # the autov3 hash, or the Civitai model/version ids. Exact-match + # semantics mean it adds no noise to ordinary keyword searches. + hash_query = search_lower.strip() + if hash_query and self._matches_hash(item, hash_query): + results.append(item) + continue return results @@ -515,6 +515,18 @@ class SearchStrategy: autov3 = item.get("autov3") if isinstance(autov3, str) and autov3 and hash_query == autov3.lower(): return True + civitai = item.get("civitai") + if isinstance(civitai, dict): + # A model card corresponds to one Civitai version: `modelId` is + # the model id (may match several cards when the library holds + # multiple versions), `id` is the version id (unique per card). + for key in ("modelId", "model_id", "id"): + value = civitai.get(key) + if value is None: + continue + value_str = str(value).strip() + if value_str and value_str != "0" and hash_query == value_str: + return True return False def _matches( diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css index d5fcca7e..4604fea2 100644 --- a/static/css/components/lora-modal/lora-modal.css +++ b/static/css/components/lora-modal/lora-modal.css @@ -292,6 +292,15 @@ font-size: 0.8em; } +/* Civitai model/version ids — pushed to the right end of the footnote line */ +.hash-footnote .civitai-id-group { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 8px; + margin-left: auto; +} + .hash-footnote .hash-copy-btn { display: inline-flex; align-items: center; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 28aa6867..5030869d 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1566,9 +1566,6 @@ 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/ModelModal.js b/static/js/components/shared/ModelModal.js index 73a84515..e66c480d 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -355,7 +355,9 @@ export async function showModelModal(model, modelType) { 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. + // full (12 chars); the full value is copied via data-hash. Civitai model / + // version ids join the same line on the right when the model comes from + // the Civitai ecosystem (a model card corresponds to one Civitai version). const modelSha256 = modelWithFullData.sha256 || ''; const modelAutov3 = modelWithFullData.autov3 || ''; const truncatedSha256 = modelSha256.length > 16 @@ -383,8 +385,47 @@ export async function showModelModal(model, modelType) { `); } - const hashesMarkup = modelSha256 && hashEntries.length ? ` -
${hashEntries.join('·')} + const normalizeCivitaiId = (value) => { + if (value === undefined || value === null) return ''; + const normalized = String(value).trim(); + // "0" is used as a placeholder for unknown ids in some metadata sources + return normalized && normalized !== '0' ? normalized : ''; + }; + const civitaiInfo = modelWithFullData.civitai || {}; + const footnoteModelId = normalizeCivitaiId(civitaiInfo.modelId ?? civitaiInfo.model_id); + const footnoteVersionId = normalizeCivitaiId(civitaiInfo.id); + const copyCivitaiIdTitle = translate('modals.model.actions.copyCivitaiId', {}, 'Copy Civitai ID'); + const civitaiIdCopiedToast = escapeAttribute(translate('modals.model.actions.civitaiIdCopied', {}, 'Civitai ID copied to clipboard')); + const civitaiIdEntries = []; + if (footnoteModelId) { + civitaiIdEntries.push(` + + ${translate('modals.model.metadata.civitaiModelId', {}, 'Model ID')} + ${escapeHtml(footnoteModelId)} + + `); + } + if (footnoteVersionId) { + civitaiIdEntries.push(` + + ${translate('modals.model.metadata.civitaiVersionId', {}, 'Version ID')} + ${escapeHtml(footnoteVersionId)} + + `); + } + const footnoteParts = []; + if (hashEntries.length) { + footnoteParts.push(hashEntries.join('·')); + } + if (civitaiIdEntries.length) { + footnoteParts.push(`${civitaiIdEntries.join('·')}`); + } + const hashesMarkup = footnoteParts.length ? ` +
${footnoteParts.join('')}
` : ''; const useNewIcons = state.global.settings.use_new_license_icons !== false; const licenseIcons = useNewIcons @@ -975,7 +1016,7 @@ function setupEventHandlers(filePath, modelType) { break; case 'copy-hash': if (target.dataset.hash) { - copyToClipboard(target.dataset.hash, 'Hash copied to clipboard'); + copyToClipboard(target.dataset.hash, target.dataset.toast || 'Hash copied to clipboard'); } break; } diff --git a/static/js/managers/SearchManager.js b/static/js/managers/SearchManager.js index c610618d..4929caf8 100644 --- a/static/js/managers/SearchManager.js +++ b/static/js/managers/SearchManager.js @@ -304,7 +304,6 @@ 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 acf76dd3..8442e793 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -123,7 +123,6 @@ export const state = { modelname: true, tags: false, creator: false, - hash: false, recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true), }, filters: { @@ -190,7 +189,6 @@ export const state = { filename: true, modelname: true, creator: false, - hash: false, recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true), }, filters: { @@ -230,7 +228,6 @@ export const state = { modelname: true, tags: false, creator: false, - hash: false, recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true), }, filters: { @@ -268,7 +265,6 @@ export const state = { modelname: true, tags: false, creator: false, - hash: false, recursive: getStorageItem(`${MODEL_TYPES.OTHER}_recursiveSearch`, true), }, filters: { diff --git a/templates/components/header.html b/templates/components/header.html index be61399b..de9d5a36 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -203,20 +203,17 @@
{{ t('header.search.filters.modelname') }}
{{ t('header.search.filters.tags') }}
{{ t('header.search.filters.creator') }}
-
{{ t('header.search.filters.hash') }}
{% elif request.path == '/embeddings' or request.path == '/other' %}
{{ 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 deleted file mode 100644 index f1977d1f..00000000 --- a/tests/frontend/api/baseModelApi.searchParams.test.js +++ /dev/null @@ -1,120 +0,0 @@ -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 index cb4ce66b..85d70430 100644 --- a/tests/frontend/components/modelModal.hashes.test.js +++ b/tests/frontend/components/modelModal.hashes.test.js @@ -165,12 +165,76 @@ describe('Model modal hash rendering', () => { expect(rows[0].querySelector('.hash-kind').textContent).toBe('SHA256'); }); - it('hides the hashes item entirely when sha256 is empty', async () => { + it('renders autov3 alone when sha256 is empty', async () => { await renderModal(makeModel({ sha256: '', autov3: AUTOV3 })); + const rows = document.querySelectorAll('.hash-footnote .hash-entry'); + expect(rows).toHaveLength(1); + expect(rows[0].querySelector('.hash-kind').textContent).toBe('AutoV3'); + }); + + it('hides the footnote entirely when there are no hashes and no civitai ids', async () => { + await renderModal(makeModel({ sha256: '', autov3: '', civitai: {} })); + expect(document.querySelector('.hash-footnote')).toBeNull(); }); + it('renders civitai model and version ids with copy buttons', async () => { + await renderModal(makeModel({ civitai: { modelId: 12345, id: 67890 } })); + + const group = document.querySelector('.hash-footnote .civitai-id-group'); + expect(group).not.toBeNull(); + + const entries = group.querySelectorAll('.civitai-id-entry'); + expect(entries).toHaveLength(2); + + expect(entries[0].querySelector('.hash-kind').textContent).toBe('Model ID'); + expect(entries[0].querySelector('.model-hash-value').textContent).toBe('12345'); + expect(entries[0].querySelector('[data-action="copy-hash"]').dataset.hash).toBe('12345'); + + expect(entries[1].querySelector('.hash-kind').textContent).toBe('Version ID'); + expect(entries[1].querySelector('.model-hash-value').textContent).toBe('67890'); + expect(entries[1].querySelector('[data-action="copy-hash"]').dataset.hash).toBe('67890'); + }); + + it('hides civitai ids for models without civitai data', async () => { + await renderModal(makeModel({ civitai: {} })); + + expect(document.querySelector('.hash-footnote .civitai-id-group')).toBeNull(); + }); + + it.each([0, '0', null, undefined, ''])('treats civitai id %s as missing', async (missingId) => { + await renderModal(makeModel({ civitai: { modelId: missingId, id: missingId } })); + + expect(document.querySelector('.hash-footnote .civitai-id-group')).toBeNull(); + }); + + it('falls back to snake_case model_id', async () => { + await renderModal(makeModel({ civitai: { model_id: 777, id: 888 } })); + + const group = document.querySelector('.hash-footnote .civitai-id-group'); + const entries = group.querySelectorAll('.civitai-id-entry'); + expect(entries[0].querySelector('.model-hash-value').textContent).toBe('777'); + expect(entries[1].querySelector('.model-hash-value').textContent).toBe('888'); + }); + + it('still renders the footnote when only civitai ids exist', async () => { + await renderModal(makeModel({ sha256: '', autov3: '', civitai: { modelId: 12345, id: 67890 } })); + + const footnote = document.querySelector('.hash-footnote'); + expect(footnote).not.toBeNull(); + expect(footnote.querySelectorAll('.civitai-id-entry')).toHaveLength(2); + }); + + it('copies the version id with the civitai toast when its copy button is clicked', async () => { + await renderModal(makeModel({ civitai: { modelId: 12345, id: 67890 } })); + + const entries = document.querySelectorAll('.civitai-id-entry [data-action="copy-hash"]'); + entries[1].click(); + + expect(copyToClipboard).toHaveBeenCalledWith('67890', 'Civitai ID copied to clipboard'); + }); + it('copies the full hash when the copy button is clicked', async () => { await renderModal(makeModel()); diff --git a/tests/services/test_model_query_search.py b/tests/services/test_model_query_search.py index 89f0aa9a..34934609 100644 --- a/tests/services/test_model_query_search.py +++ b/tests/services/test_model_query_search.py @@ -13,20 +13,13 @@ SHA256 = "abcdef1234567890" + "f" * 48 # 64-char hex AUTOV2 = SHA256[:10] AUTOV3 = "0123456789ab" +# Hash/id exact matching is always on; these options only disable the +# scope-based branches so tests exercise the hash branch in isolation. 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, } @@ -88,9 +81,11 @@ class TestSearchStrategyHash: 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_hash_matching_is_always_on(self, strategy): + # There is no hash option anymore: exact hash/id matching happens + # regardless of the search scope options. + assert len(strategy.apply([make_item()], SHA256, HASH_ONLY_OPTIONS)) == 1 + assert len(strategy.apply([make_item()], AUTOV3, HASH_ONLY_OPTIONS)) == 1 def test_fuzzy_mode_still_exact(self, strategy): # Fuzzy matching must never apply to the hash field @@ -104,6 +99,48 @@ class TestSearchStrategyHash: assert strategy.apply([item], SHA256, HASH_ONLY_OPTIONS) == [] +class TestSearchStrategyCivitaiIds: + """Hash search also exact-matches Civitai model/version ids.""" + + def test_version_id_matches(self, strategy): + item = make_item(civitai={"modelId": 12345, "id": 67890}) + assert len(strategy.apply([item], "67890", HASH_ONLY_OPTIONS)) == 1 + + def test_model_id_matches(self, strategy): + item = make_item(civitai={"modelId": 12345, "id": 67890}) + assert len(strategy.apply([item], "12345", HASH_ONLY_OPTIONS)) == 1 + + def test_model_id_matches_all_versions_of_the_model(self, strategy): + v1 = make_item(file_name="v1.safetensors", civitai={"modelId": 12345, "id": 111}) + v2 = make_item(file_name="v2.safetensors", civitai={"modelId": 12345, "id": 222}) + other = make_item(file_name="other.safetensors", civitai={"modelId": 999, "id": 333}) + result = strategy.apply([v1, v2, other], "12345", HASH_ONLY_OPTIONS) + assert {r["file_name"] for r in result} == {"v1.safetensors", "v2.safetensors"} + + def test_snake_case_model_id_matches(self, strategy): + item = make_item(civitai={"model_id": 12345, "id": 67890}) + assert len(strategy.apply([item], "12345", HASH_ONLY_OPTIONS)) == 1 + + def test_string_ids_match(self, strategy): + item = make_item(civitai={"modelId": "12345", "id": "67890"}) + assert len(strategy.apply([item], "67890", HASH_ONLY_OPTIONS)) == 1 + + def test_unknown_id_does_not_match(self, strategy): + item = make_item(civitai={"modelId": 12345, "id": 67890}) + assert strategy.apply([item], "555", HASH_ONLY_OPTIONS) == [] + + @pytest.mark.parametrize("missing", [0, "0", None, ""]) + def test_placeholder_ids_do_not_match(self, strategy, missing): + item = make_item(sha256="", autov3=None, civitai={"modelId": missing, "id": missing}) + assert strategy.apply([item], "0", HASH_ONLY_OPTIONS) == [] + + def test_missing_civitai_data_does_not_match(self, strategy): + item = make_item(sha256="", autov3=None, civitai=None) + assert strategy.apply([item], "12345", HASH_ONLY_OPTIONS) == [] + item = make_item(sha256="", autov3=None) + assert strategy.apply([item], "12345", HASH_ONLY_OPTIONS) == [] + + class TestFormatResponseAutov3: """format_response should pass the autov3 field through unchanged."""