mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-03 00:35:32 -03:00
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
This commit is contained in:
@@ -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"),
|
> style. All 9 locales are translated (terminology in §2, "Download routing feature"),
|
||||||
> so the "no remaining placeholders" claim holds again.
|
> 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)
|
## 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 「 」,
|
label verbatim with each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」,
|
||||||
ko `' '`, fr « … », de „ … “, es/ru/he «…»).
|
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)
|
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "Modellname",
|
"modelname": "Modellname",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"creator": "Ersteller",
|
"creator": "Ersteller",
|
||||||
"hash": "Hash",
|
|
||||||
"title": "Rezept-Titel",
|
"title": "Rezept-Titel",
|
||||||
"loraName": "LoRA-Dateiname",
|
"loraName": "LoRA-Dateiname",
|
||||||
"loraModel": "LoRA-Modellname",
|
"loraModel": "LoRA-Modellname",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "An ComfyUI senden",
|
"sendToWorkflow": "An ComfyUI senden",
|
||||||
"sendToWorkflowText": "An ComfyUI senden",
|
"sendToWorkflowText": "An ComfyUI senden",
|
||||||
"copyHash": "Hash kopieren",
|
"copyHash": "Hash kopieren",
|
||||||
|
"copyCivitaiId": "Civitai-ID kopieren",
|
||||||
|
"civitaiIdCopied": "Civitai-ID in die Zwischenablage kopiert",
|
||||||
"deleteModelWithShortcut": "Modell löschen (Del)"
|
"deleteModelWithShortcut": "Modell löschen (Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "Basismodell",
|
"baseModel": "Basismodell",
|
||||||
"size": "Größe",
|
"size": "Größe",
|
||||||
"hashes": "Hashes",
|
"hashes": "Hashes",
|
||||||
|
"civitaiModelId": "Modell-ID",
|
||||||
|
"civitaiVersionId": "Versions-ID",
|
||||||
"unknown": "Unbekannt",
|
"unknown": "Unbekannt",
|
||||||
"usageTips": "Nutzungstipps",
|
"usageTips": "Nutzungstipps",
|
||||||
"additionalNotes": "Zusätzliche Notizen",
|
"additionalNotes": "Zusätzliche Notizen",
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "Model Name",
|
"modelname": "Model Name",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"creator": "Creator",
|
"creator": "Creator",
|
||||||
"hash": "Hash",
|
|
||||||
"title": "Recipe Title",
|
"title": "Recipe Title",
|
||||||
"loraName": "LoRA Filename",
|
"loraName": "LoRA Filename",
|
||||||
"loraModel": "LoRA Model Name",
|
"loraModel": "LoRA Model Name",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "Send to ComfyUI",
|
"sendToWorkflow": "Send to ComfyUI",
|
||||||
"sendToWorkflowText": "Send to ComfyUI",
|
"sendToWorkflowText": "Send to ComfyUI",
|
||||||
"copyHash": "Copy hash",
|
"copyHash": "Copy hash",
|
||||||
|
"copyCivitaiId": "Copy Civitai ID",
|
||||||
|
"civitaiIdCopied": "Civitai ID copied to clipboard",
|
||||||
"deleteModelWithShortcut": "Delete model (Del)"
|
"deleteModelWithShortcut": "Delete model (Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "Base Model",
|
"baseModel": "Base Model",
|
||||||
"size": "Size",
|
"size": "Size",
|
||||||
"hashes": "Hashes",
|
"hashes": "Hashes",
|
||||||
|
"civitaiModelId": "Model ID",
|
||||||
|
"civitaiVersionId": "Version ID",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"usageTips": "Usage Tips",
|
"usageTips": "Usage Tips",
|
||||||
"additionalNotes": "Additional Notes",
|
"additionalNotes": "Additional Notes",
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "Nombre del modelo",
|
"modelname": "Nombre del modelo",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
"creator": "Creador",
|
"creator": "Creador",
|
||||||
"hash": "Hash",
|
|
||||||
"title": "Título de la receta",
|
"title": "Título de la receta",
|
||||||
"loraName": "Nombre de archivo LoRA",
|
"loraName": "Nombre de archivo LoRA",
|
||||||
"loraModel": "Nombre del modelo LoRA",
|
"loraModel": "Nombre del modelo LoRA",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "Enviar a ComfyUI",
|
"sendToWorkflow": "Enviar a ComfyUI",
|
||||||
"sendToWorkflowText": "Enviar a ComfyUI",
|
"sendToWorkflowText": "Enviar a ComfyUI",
|
||||||
"copyHash": "Copiar hash",
|
"copyHash": "Copiar hash",
|
||||||
|
"copyCivitaiId": "Copiar ID de Civitai",
|
||||||
|
"civitaiIdCopied": "ID de Civitai copiado al portapapeles",
|
||||||
"deleteModelWithShortcut": "Eliminar modelo (Del)"
|
"deleteModelWithShortcut": "Eliminar modelo (Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "Modelo base",
|
"baseModel": "Modelo base",
|
||||||
"size": "Tamaño",
|
"size": "Tamaño",
|
||||||
"hashes": "Hashes",
|
"hashes": "Hashes",
|
||||||
|
"civitaiModelId": "ID del modelo",
|
||||||
|
"civitaiVersionId": "ID de versión",
|
||||||
"unknown": "Desconocido",
|
"unknown": "Desconocido",
|
||||||
"usageTips": "Consejos de uso",
|
"usageTips": "Consejos de uso",
|
||||||
"additionalNotes": "Notas adicionales",
|
"additionalNotes": "Notas adicionales",
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "Nom du modèle",
|
"modelname": "Nom du modèle",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"creator": "Créateur",
|
"creator": "Créateur",
|
||||||
"hash": "Hash",
|
|
||||||
"title": "Titre de la recipe",
|
"title": "Titre de la recipe",
|
||||||
"loraName": "Nom de fichier LoRA",
|
"loraName": "Nom de fichier LoRA",
|
||||||
"loraModel": "Nom du modèle LoRA",
|
"loraModel": "Nom du modèle LoRA",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||||
"sendToWorkflowText": "Envoyer vers ComfyUI",
|
"sendToWorkflowText": "Envoyer vers ComfyUI",
|
||||||
"copyHash": "Copier le hash",
|
"copyHash": "Copier le hash",
|
||||||
|
"copyCivitaiId": "Copier l’ID Civitai",
|
||||||
|
"civitaiIdCopied": "ID Civitai copié dans le presse-papiers",
|
||||||
"deleteModelWithShortcut": "Supprimer le modèle (Del)"
|
"deleteModelWithShortcut": "Supprimer le modèle (Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "Modèle de base",
|
"baseModel": "Modèle de base",
|
||||||
"size": "Taille",
|
"size": "Taille",
|
||||||
"hashes": "Hashes",
|
"hashes": "Hashes",
|
||||||
|
"civitaiModelId": "ID du modèle",
|
||||||
|
"civitaiVersionId": "ID de version",
|
||||||
"unknown": "Inconnu",
|
"unknown": "Inconnu",
|
||||||
"usageTips": "Conseils d'utilisation",
|
"usageTips": "Conseils d'utilisation",
|
||||||
"additionalNotes": "Notes supplémentaires",
|
"additionalNotes": "Notes supplémentaires",
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "שם מודל",
|
"modelname": "שם מודל",
|
||||||
"tags": "תגיות",
|
"tags": "תגיות",
|
||||||
"creator": "יוצר",
|
"creator": "יוצר",
|
||||||
"hash": "hash",
|
|
||||||
"title": "כותרת מתכון",
|
"title": "כותרת מתכון",
|
||||||
"loraName": "שם קובץ LoRA",
|
"loraName": "שם קובץ LoRA",
|
||||||
"loraModel": "שם מודל LoRA",
|
"loraModel": "שם מודל LoRA",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||||
"sendToWorkflowText": "שלח ל-ComfyUI",
|
"sendToWorkflowText": "שלח ל-ComfyUI",
|
||||||
"copyHash": "העתק hash",
|
"copyHash": "העתק hash",
|
||||||
|
"copyCivitaiId": "העתק מזהה Civitai",
|
||||||
|
"civitaiIdCopied": "מזהה Civitai הועתק ללוח העריכה",
|
||||||
"deleteModelWithShortcut": "מחק מודל (Del)"
|
"deleteModelWithShortcut": "מחק מודל (Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "מודל בסיס",
|
"baseModel": "מודל בסיס",
|
||||||
"size": "גודל",
|
"size": "גודל",
|
||||||
"hashes": "hashes",
|
"hashes": "hashes",
|
||||||
|
"civitaiModelId": "מזהה מודל",
|
||||||
|
"civitaiVersionId": "מזהה גרסה",
|
||||||
"unknown": "לא ידוע",
|
"unknown": "לא ידוע",
|
||||||
"usageTips": "טיפים לשימוש",
|
"usageTips": "טיפים לשימוש",
|
||||||
"additionalNotes": "הערות נוספות",
|
"additionalNotes": "הערות נוספות",
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "モデル名",
|
"modelname": "モデル名",
|
||||||
"tags": "タグ",
|
"tags": "タグ",
|
||||||
"creator": "作成者",
|
"creator": "作成者",
|
||||||
"hash": "ハッシュ",
|
|
||||||
"title": "レシピタイトル",
|
"title": "レシピタイトル",
|
||||||
"loraName": "LoRAファイル名",
|
"loraName": "LoRAファイル名",
|
||||||
"loraModel": "LoRAモデル名",
|
"loraModel": "LoRAモデル名",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "ComfyUI に送信",
|
"sendToWorkflow": "ComfyUI に送信",
|
||||||
"sendToWorkflowText": "ComfyUI に送信",
|
"sendToWorkflowText": "ComfyUI に送信",
|
||||||
"copyHash": "ハッシュをコピー",
|
"copyHash": "ハッシュをコピー",
|
||||||
|
"copyCivitaiId": "Civitai IDをコピー",
|
||||||
|
"civitaiIdCopied": "Civitai IDをクリップボードにコピーしました",
|
||||||
"deleteModelWithShortcut": "モデルを削除(Del)"
|
"deleteModelWithShortcut": "モデルを削除(Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "ベースモデル",
|
"baseModel": "ベースモデル",
|
||||||
"size": "サイズ",
|
"size": "サイズ",
|
||||||
"hashes": "ハッシュ",
|
"hashes": "ハッシュ",
|
||||||
|
"civitaiModelId": "モデル ID",
|
||||||
|
"civitaiVersionId": "バージョン ID",
|
||||||
"unknown": "不明",
|
"unknown": "不明",
|
||||||
"usageTips": "使用のヒント",
|
"usageTips": "使用のヒント",
|
||||||
"additionalNotes": "追加メモ",
|
"additionalNotes": "追加メモ",
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "모델명",
|
"modelname": "모델명",
|
||||||
"tags": "태그",
|
"tags": "태그",
|
||||||
"creator": "제작자",
|
"creator": "제작자",
|
||||||
"hash": "해시",
|
|
||||||
"title": "레시피 제목",
|
"title": "레시피 제목",
|
||||||
"loraName": "LoRA 파일명",
|
"loraName": "LoRA 파일명",
|
||||||
"loraModel": "LoRA 모델명",
|
"loraModel": "LoRA 모델명",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "ComfyUI로 보내기",
|
"sendToWorkflow": "ComfyUI로 보내기",
|
||||||
"sendToWorkflowText": "ComfyUI로 보내기",
|
"sendToWorkflowText": "ComfyUI로 보내기",
|
||||||
"copyHash": "해시 복사",
|
"copyHash": "해시 복사",
|
||||||
|
"copyCivitaiId": "Civitai ID 복사",
|
||||||
|
"civitaiIdCopied": "Civitai ID가 클립보드에 복사되었습니다",
|
||||||
"deleteModelWithShortcut": "모델 삭제(Del)"
|
"deleteModelWithShortcut": "모델 삭제(Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "베이스 모델",
|
"baseModel": "베이스 모델",
|
||||||
"size": "크기",
|
"size": "크기",
|
||||||
"hashes": "해시",
|
"hashes": "해시",
|
||||||
|
"civitaiModelId": "모델 ID",
|
||||||
|
"civitaiVersionId": "버전 ID",
|
||||||
"unknown": "알 수 없음",
|
"unknown": "알 수 없음",
|
||||||
"usageTips": "사용 팁",
|
"usageTips": "사용 팁",
|
||||||
"additionalNotes": "추가 메모",
|
"additionalNotes": "추가 메모",
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "Название модели",
|
"modelname": "Название модели",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
"creator": "Автор",
|
"creator": "Автор",
|
||||||
"hash": "Хэш",
|
|
||||||
"title": "Название рецепта",
|
"title": "Название рецепта",
|
||||||
"loraName": "Имя файла LoRA",
|
"loraName": "Имя файла LoRA",
|
||||||
"loraModel": "Название модели LoRA",
|
"loraModel": "Название модели LoRA",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "Отправить в ComfyUI",
|
"sendToWorkflow": "Отправить в ComfyUI",
|
||||||
"sendToWorkflowText": "Отправить в ComfyUI",
|
"sendToWorkflowText": "Отправить в ComfyUI",
|
||||||
"copyHash": "Копировать хеш",
|
"copyHash": "Копировать хеш",
|
||||||
|
"copyCivitaiId": "Копировать ID Civitai",
|
||||||
|
"civitaiIdCopied": "ID Civitai скопирован в буфер обмена",
|
||||||
"deleteModelWithShortcut": "Удалить модель (Del)"
|
"deleteModelWithShortcut": "Удалить модель (Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "Базовая модель",
|
"baseModel": "Базовая модель",
|
||||||
"size": "Размер",
|
"size": "Размер",
|
||||||
"hashes": "Хэши",
|
"hashes": "Хэши",
|
||||||
|
"civitaiModelId": "ID модели",
|
||||||
|
"civitaiVersionId": "ID версии",
|
||||||
"unknown": "Неизвестно",
|
"unknown": "Неизвестно",
|
||||||
"usageTips": "Советы по использованию",
|
"usageTips": "Советы по использованию",
|
||||||
"additionalNotes": "Дополнительные заметки",
|
"additionalNotes": "Дополнительные заметки",
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "模型名称",
|
"modelname": "模型名称",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
"creator": "创作者",
|
"creator": "创作者",
|
||||||
"hash": "哈希",
|
|
||||||
"title": "配方标题",
|
"title": "配方标题",
|
||||||
"loraName": "LoRA 文件名",
|
"loraName": "LoRA 文件名",
|
||||||
"loraModel": "LoRA 模型名称",
|
"loraModel": "LoRA 模型名称",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "发送到 ComfyUI",
|
"sendToWorkflow": "发送到 ComfyUI",
|
||||||
"sendToWorkflowText": "发送到 ComfyUI",
|
"sendToWorkflowText": "发送到 ComfyUI",
|
||||||
"copyHash": "复制哈希值",
|
"copyHash": "复制哈希值",
|
||||||
|
"copyCivitaiId": "复制 Civitai ID",
|
||||||
|
"civitaiIdCopied": "Civitai ID 已复制到剪贴板",
|
||||||
"deleteModelWithShortcut": "删除模型(Del)"
|
"deleteModelWithShortcut": "删除模型(Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "基础模型",
|
"baseModel": "基础模型",
|
||||||
"size": "大小",
|
"size": "大小",
|
||||||
"hashes": "哈希值",
|
"hashes": "哈希值",
|
||||||
|
"civitaiModelId": "模型 ID",
|
||||||
|
"civitaiVersionId": "版本 ID",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"usageTips": "使用提示",
|
"usageTips": "使用提示",
|
||||||
"additionalNotes": "附加备注",
|
"additionalNotes": "附加备注",
|
||||||
|
|||||||
+4
-1
@@ -251,7 +251,6 @@
|
|||||||
"modelname": "模型名稱",
|
"modelname": "模型名稱",
|
||||||
"tags": "標籤",
|
"tags": "標籤",
|
||||||
"creator": "創作者",
|
"creator": "創作者",
|
||||||
"hash": "雜湊",
|
|
||||||
"title": "配方標題",
|
"title": "配方標題",
|
||||||
"loraName": "LoRA 檔案名稱",
|
"loraName": "LoRA 檔案名稱",
|
||||||
"loraModel": "LoRA 模型名稱",
|
"loraModel": "LoRA 模型名稱",
|
||||||
@@ -1837,6 +1836,8 @@
|
|||||||
"sendToWorkflow": "傳送到 ComfyUI",
|
"sendToWorkflow": "傳送到 ComfyUI",
|
||||||
"sendToWorkflowText": "傳送到 ComfyUI",
|
"sendToWorkflowText": "傳送到 ComfyUI",
|
||||||
"copyHash": "複製雜湊值",
|
"copyHash": "複製雜湊值",
|
||||||
|
"copyCivitaiId": "複製 Civitai ID",
|
||||||
|
"civitaiIdCopied": "Civitai ID 已複製到剪貼簿",
|
||||||
"deleteModelWithShortcut": "刪除模型(Del)"
|
"deleteModelWithShortcut": "刪除模型(Del)"
|
||||||
},
|
},
|
||||||
"openFileLocation": {
|
"openFileLocation": {
|
||||||
@@ -1855,6 +1856,8 @@
|
|||||||
"baseModel": "基礎模型",
|
"baseModel": "基礎模型",
|
||||||
"size": "大小",
|
"size": "大小",
|
||||||
"hashes": "雜湊值",
|
"hashes": "雜湊值",
|
||||||
|
"civitaiModelId": "模型 ID",
|
||||||
|
"civitaiVersionId": "版本 ID",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
"usageTips": "使用提示",
|
"usageTips": "使用提示",
|
||||||
"additionalNotes": "附加備註",
|
"additionalNotes": "附加備註",
|
||||||
|
|||||||
@@ -387,7 +387,6 @@ class ModelListingHandler:
|
|||||||
== "true",
|
== "true",
|
||||||
"tags": request.query.get("search_tags", "false").lower() == "true",
|
"tags": request.query.get("search_tags", "false").lower() == "true",
|
||||||
"creator": request.query.get("search_creator", "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",
|
"recursive": request.query.get("recursive", "true").lower() == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -432,7 +432,6 @@ class SearchStrategy:
|
|||||||
"tags": False,
|
"tags": False,
|
||||||
"recursive": True,
|
"recursive": True,
|
||||||
"creator": False,
|
"creator": False,
|
||||||
"hash": False,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -495,13 +494,14 @@ class SearchStrategy:
|
|||||||
results.append(item)
|
results.append(item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Hash search is always exact (never fuzzy): match the full
|
# Hash/id search is always exact (never fuzzy) and always on: it
|
||||||
# sha256, its autov2 prefix (first 10 chars), or the autov3 hash.
|
# matches the full sha256, its autov2 prefix (first 10 chars),
|
||||||
if options.get("hash", False):
|
# the autov3 hash, or the Civitai model/version ids. Exact-match
|
||||||
hash_query = search_lower.strip()
|
# semantics mean it adds no noise to ordinary keyword searches.
|
||||||
if hash_query and self._matches_hash(item, hash_query):
|
hash_query = search_lower.strip()
|
||||||
results.append(item)
|
if hash_query and self._matches_hash(item, hash_query):
|
||||||
continue
|
results.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -515,6 +515,18 @@ class SearchStrategy:
|
|||||||
autov3 = item.get("autov3")
|
autov3 = item.get("autov3")
|
||||||
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
|
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
def _matches(
|
def _matches(
|
||||||
|
|||||||
@@ -292,6 +292,15 @@
|
|||||||
font-size: 0.8em;
|
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 {
|
.hash-footnote .hash-copy-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1566,9 +1566,6 @@ export class BaseModelApiClient {
|
|||||||
if (pageState.searchOptions.creator !== undefined) {
|
if (pageState.searchOptions.creator !== undefined) {
|
||||||
params.append('search_creator', pageState.searchOptions.creator.toString());
|
params.append('search_creator', pageState.searchOptions.creator.toString());
|
||||||
}
|
}
|
||||||
if (pageState.searchOptions.hash !== undefined) {
|
|
||||||
params.append('search_hash', pageState.searchOptions.hash.toString());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -355,7 +355,9 @@ export async function showModelModal(model, modelType) {
|
|||||||
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
|
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
|
||||||
// De-emphasized hash display: a borderless full-width footnote line below
|
// De-emphasized hash display: a borderless full-width footnote line below
|
||||||
// the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in
|
// 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 modelSha256 = modelWithFullData.sha256 || '';
|
||||||
const modelAutov3 = modelWithFullData.autov3 || '';
|
const modelAutov3 = modelWithFullData.autov3 || '';
|
||||||
const truncatedSha256 = modelSha256.length > 16
|
const truncatedSha256 = modelSha256.length > 16
|
||||||
@@ -383,8 +385,47 @@ export async function showModelModal(model, modelType) {
|
|||||||
</button>
|
</button>
|
||||||
</span>`);
|
</span>`);
|
||||||
}
|
}
|
||||||
const hashesMarkup = modelSha256 && hashEntries.length ? `
|
const normalizeCivitaiId = (value) => {
|
||||||
<div class="hash-footnote" aria-label="${translate('modals.model.metadata.hashes', {}, 'Hashes')}">${hashEntries.join('<span class="hash-sep">·</span>')}
|
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(`
|
||||||
|
<span class="hash-entry civitai-id-entry">
|
||||||
|
<span class="hash-kind">${translate('modals.model.metadata.civitaiModelId', {}, 'Model ID')}</span>
|
||||||
|
<span class="model-hash-value" title="${escapeAttribute(footnoteModelId)}">${escapeHtml(footnoteModelId)}</span>
|
||||||
|
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(footnoteModelId)}" data-toast="${civitaiIdCopiedToast}" title="${copyCivitaiIdTitle}">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</span>`);
|
||||||
|
}
|
||||||
|
if (footnoteVersionId) {
|
||||||
|
civitaiIdEntries.push(`
|
||||||
|
<span class="hash-entry civitai-id-entry">
|
||||||
|
<span class="hash-kind">${translate('modals.model.metadata.civitaiVersionId', {}, 'Version ID')}</span>
|
||||||
|
<span class="model-hash-value" title="${escapeAttribute(footnoteVersionId)}">${escapeHtml(footnoteVersionId)}</span>
|
||||||
|
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(footnoteVersionId)}" data-toast="${civitaiIdCopiedToast}" title="${copyCivitaiIdTitle}">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</span>`);
|
||||||
|
}
|
||||||
|
const footnoteParts = [];
|
||||||
|
if (hashEntries.length) {
|
||||||
|
footnoteParts.push(hashEntries.join('<span class="hash-sep">·</span>'));
|
||||||
|
}
|
||||||
|
if (civitaiIdEntries.length) {
|
||||||
|
footnoteParts.push(`<span class="civitai-id-group">${civitaiIdEntries.join('<span class="hash-sep">·</span>')}</span>`);
|
||||||
|
}
|
||||||
|
const hashesMarkup = footnoteParts.length ? `
|
||||||
|
<div class="hash-footnote" aria-label="${translate('modals.model.metadata.hashes', {}, 'Hashes')}">${footnoteParts.join('')}
|
||||||
</div>` : '';
|
</div>` : '';
|
||||||
const useNewIcons = state.global.settings.use_new_license_icons !== false;
|
const useNewIcons = state.global.settings.use_new_license_icons !== false;
|
||||||
const licenseIcons = useNewIcons
|
const licenseIcons = useNewIcons
|
||||||
@@ -975,7 +1016,7 @@ function setupEventHandlers(filePath, modelType) {
|
|||||||
break;
|
break;
|
||||||
case 'copy-hash':
|
case 'copy-hash':
|
||||||
if (target.dataset.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;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,7 +304,6 @@ export class SearchManager {
|
|||||||
pageState.searchOptions.modelname = options.modelname || false;
|
pageState.searchOptions.modelname = options.modelname || false;
|
||||||
pageState.searchOptions.tags = options.tags || false;
|
pageState.searchOptions.tags = options.tags || false;
|
||||||
pageState.searchOptions.creator = options.creator || false;
|
pageState.searchOptions.creator = options.creator || false;
|
||||||
pageState.searchOptions.hash = options.hash || false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export const state = {
|
|||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
creator: false,
|
creator: false,
|
||||||
hash: false,
|
|
||||||
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
|
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
@@ -190,7 +189,6 @@ export const state = {
|
|||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
creator: false,
|
creator: false,
|
||||||
hash: false,
|
|
||||||
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
|
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
@@ -230,7 +228,6 @@ export const state = {
|
|||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
creator: false,
|
creator: false,
|
||||||
hash: false,
|
|
||||||
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
|
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
@@ -268,7 +265,6 @@ export const state = {
|
|||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
creator: false,
|
creator: false,
|
||||||
hash: false,
|
|
||||||
recursive: getStorageItem(`${MODEL_TYPES.OTHER}_recursiveSearch`, true),
|
recursive: getStorageItem(`${MODEL_TYPES.OTHER}_recursiveSearch`, true),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
|
|||||||
@@ -203,20 +203,17 @@
|
|||||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||||
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
||||||
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
|
|
||||||
{% elif request.path == '/embeddings' or request.path == '/other' %}
|
{% elif request.path == '/embeddings' or request.path == '/other' %}
|
||||||
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
||||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||||
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
||||||
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Default options for LoRAs page -->
|
<!-- Default options for LoRAs page -->
|
||||||
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
||||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||||
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
|
||||||
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -165,12 +165,76 @@ describe('Model modal hash rendering', () => {
|
|||||||
expect(rows[0].querySelector('.hash-kind').textContent).toBe('SHA256');
|
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 }));
|
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();
|
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 () => {
|
it('copies the full hash when the copy button is clicked', async () => {
|
||||||
await renderModal(makeModel());
|
await renderModal(makeModel());
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,13 @@ SHA256 = "abcdef1234567890" + "f" * 48 # 64-char hex
|
|||||||
AUTOV2 = SHA256[:10]
|
AUTOV2 = SHA256[:10]
|
||||||
AUTOV3 = "0123456789ab"
|
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 = {
|
HASH_ONLY_OPTIONS = {
|
||||||
"filename": False,
|
"filename": False,
|
||||||
"modelname": False,
|
"modelname": False,
|
||||||
"tags": False,
|
"tags": False,
|
||||||
"creator": 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="")
|
item = make_item(autov3="")
|
||||||
assert strategy.apply([item], AUTOV3, HASH_ONLY_OPTIONS) == []
|
assert strategy.apply([item], AUTOV3, HASH_ONLY_OPTIONS) == []
|
||||||
|
|
||||||
def test_hash_option_disabled(self, strategy):
|
def test_hash_matching_is_always_on(self, strategy):
|
||||||
assert strategy.apply([make_item()], SHA256, HASH_OFF_OPTIONS) == []
|
# There is no hash option anymore: exact hash/id matching happens
|
||||||
assert strategy.apply([make_item()], AUTOV3, HASH_OFF_OPTIONS) == []
|
# 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):
|
def test_fuzzy_mode_still_exact(self, strategy):
|
||||||
# Fuzzy matching must never apply to the hash field
|
# Fuzzy matching must never apply to the hash field
|
||||||
@@ -104,6 +99,48 @@ class TestSearchStrategyHash:
|
|||||||
assert strategy.apply([item], SHA256, HASH_ONLY_OPTIONS) == []
|
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:
|
class TestFormatResponseAutov3:
|
||||||
"""format_response should pass the autov3 field through unchanged."""
|
"""format_response should pass the autov3 field through unchanged."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user