From fa7ce725c11cffb9e55dc67fecb31b7c954e6af5 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 12 Sep 2026 11:25:51 +0800 Subject: [PATCH] feat(frontend): add Other Models page with subtype filter and badges --- locales/de.json | 9 + locales/en.json | 9 + locales/es.json | 9 + locales/fr.json | 9 + locales/he.json | 9 + locales/ja.json | 9 + locales/ko.json | 9 + locales/ru.json | 9 + locales/zh-CN.json | 9 + locales/zh-TW.json | 9 + static/js/api/apiConfig.js | 15 +- static/js/api/modelApiFactory.js | 3 + static/js/api/otherApi.js | 7 + .../components/ContextMenu/BulkContextMenu.js | 4 +- .../ContextMenu/ModelContextMenuMixin.js | 3 +- .../ContextMenu/OtherContextMenu.js | 72 ++++++++ static/js/components/ContextMenu/index.js | 4 + static/js/components/Header.js | 1 + static/js/components/SidebarManager.js | 3 +- .../js/components/controls/OtherControls.js | 60 +++++++ static/js/components/controls/index.js | 9 +- static/js/components/initialization.js | 3 + static/js/components/shared/ModelCard.js | 5 + static/js/core.js | 2 +- static/js/managers/BannerService.js | 6 +- static/js/managers/BulkManager.js | 14 ++ static/js/managers/FilterManager.js | 4 +- static/js/managers/MoveManager.js | 14 +- static/js/managers/SearchManager.js | 4 +- static/js/managers/SettingsManager.js | 3 + static/js/other.js | 57 +++++++ static/js/state/index.js | 39 +++++ static/js/utils/activeFiltersSync.js | 2 +- static/js/utils/constants.js | 11 ++ static/js/utils/infiniteScroll.js | 2 +- static/js/utils/storageHelpers.js | 2 +- templates/components/controls.html | 4 +- templates/components/header.html | 10 +- templates/other.html | 74 ++++++++ tests/frontend/api/otherApi.test.js | 85 ++++++++++ .../components/modelCard.subTypeBadge.test.js | 128 ++++++++++++++ .../components/otherFactories.test.js | 159 ++++++++++++++++++ tests/frontend/core/appCore.test.js | 2 +- tests/frontend/pages/otherPage.test.js | 105 ++++++++++++ tests/frontend/utils/pageFixtures.js | 12 ++ 45 files changed, 988 insertions(+), 30 deletions(-) create mode 100644 static/js/api/otherApi.js create mode 100644 static/js/components/ContextMenu/OtherContextMenu.js create mode 100644 static/js/components/controls/OtherControls.js create mode 100644 static/js/other.js create mode 100644 templates/other.html create mode 100644 tests/frontend/api/otherApi.test.js create mode 100644 tests/frontend/components/modelCard.subTypeBadge.test.js create mode 100644 tests/frontend/components/otherFactories.test.js create mode 100644 tests/frontend/pages/otherPage.test.js diff --git a/locales/de.json b/locales/de.json index a4bdf46b..db83bcbe 100644 --- a/locales/de.json +++ b/locales/de.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Checkpoint-Name kopieren", "copyEmbeddingName": "Embedding-Name kopieren", "embeddingNameCopied": "Embedding-Syntax kopiert", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "An ComfyUI senden", "sendEmbeddingToWorkflow": "An ComfyUI senden" }, @@ -233,6 +234,7 @@ "recipes": "Rezepte", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "Statistiken" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding-Modelle" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "Stammverzeichnis", "collapseAll": "Alle Ordner einklappen", @@ -1878,6 +1883,10 @@ "title": "Embedding Manager wird initialisiert", "message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..." }, + "other": { + "title": "[TODO: Translate] Initializing Other Models Manager", + "message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "Rezept Manager wird initialisiert", "message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..." diff --git a/locales/en.json b/locales/en.json index b6c9e93b..c5e45b63 100644 --- a/locales/en.json +++ b/locales/en.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Copy checkpoint name", "copyEmbeddingName": "Copy embedding name", "embeddingNameCopied": "Embedding syntax copied", + "modelNameCopied": "Model name copied", "sendCheckpointToWorkflow": "Send to ComfyUI", "sendEmbeddingToWorkflow": "Send to ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "Recipes", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "Other", "statistics": "Stats" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding Models" }, + "other": { + "title": "Other Models" + }, "sidebar": { "modelRoot": "Root", "collapseAll": "Collapse All Folders", @@ -1878,6 +1883,10 @@ "title": "Initializing Embedding Manager", "message": "Scanning and building embedding cache. This may take a few minutes..." }, + "other": { + "title": "Initializing Other Models Manager", + "message": "Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "Initializing Recipe Manager", "message": "Loading and processing recipes. This may take a few minutes..." diff --git a/locales/es.json b/locales/es.json index 6a555902..7d32f6ac 100644 --- a/locales/es.json +++ b/locales/es.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Copiar nombre del checkpoint", "copyEmbeddingName": "Copiar nombre del embedding", "embeddingNameCopied": "Sintaxis de embedding copiada", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "Enviar a ComfyUI", "sendEmbeddingToWorkflow": "Enviar a ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "Recetas", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "Estadísticas" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Modelos embedding" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "Raíz", "collapseAll": "Colapsar todas las carpetas", @@ -1878,6 +1883,10 @@ "title": "Inicializando gestor de embedding", "message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..." }, + "other": { + "title": "[TODO: Translate] Initializing Other Models Manager", + "message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "Inicializando gestor de recetas", "message": "Cargando y procesando recetas. Esto puede tomar unos minutos..." diff --git a/locales/fr.json b/locales/fr.json index 9fbfa1fd..9d3464f3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Copier le nom du checkpoint", "copyEmbeddingName": "Copier le nom de l'embedding", "embeddingNameCopied": "Syntaxe dembedding copiée", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "Envoyer vers ComfyUI", "sendEmbeddingToWorkflow": "Envoyer vers ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "Recipes", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "Statistiques" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Modèles Embedding" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "Racine", "collapseAll": "Réduire tous les dossiers", @@ -1878,6 +1883,10 @@ "title": "Initialisation du gestionnaire Embedding", "message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..." }, + "other": { + "title": "[TODO: Translate] Initializing Other Models Manager", + "message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "Initialisation du gestionnaire de recipes", "message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..." diff --git a/locales/he.json b/locales/he.json index 589a34d0..d0da7ac1 100644 --- a/locales/he.json +++ b/locales/he.json @@ -149,6 +149,7 @@ "copyCheckpointName": "העתק שם Checkpoint", "copyEmbeddingName": "העתק שם Embedding", "embeddingNameCopied": "תחביר Embedding הועתק", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "שלח ל-ComfyUI", "sendEmbeddingToWorkflow": "שלח ל-ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "מתכונים", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "סטטיסטיקה" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "מודלי Embedding" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "שורש", "collapseAll": "כווץ את כל התיקיות", @@ -1878,6 +1883,10 @@ "title": "מאתחל מנהל Embedding", "message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..." }, + "other": { + "title": "[TODO: Translate] Initializing Other Models Manager", + "message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "מאתחל מנהל מתכונים", "message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..." diff --git a/locales/ja.json b/locales/ja.json index 76698bc6..afc1edbb 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Checkpoint名をコピー", "copyEmbeddingName": "embedding名をコピー", "embeddingNameCopied": "Embedding構文をコピーしました", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "ComfyUIに送信", "sendEmbeddingToWorkflow": "ComfyUIに送信" }, @@ -233,6 +234,7 @@ "recipes": "レシピ", "checkpoints": "Checkpoint", "embeddings": "Embedding", + "other": "[TODO: Translate] Other", "statistics": "統計" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embeddingモデル" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "ルート", "collapseAll": "すべてのフォルダを折りたたむ", @@ -1878,6 +1883,10 @@ "title": "Embedding Managerを初期化中", "message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..." }, + "other": { + "title": "[TODO: Translate] Initializing Other Models Manager", + "message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "レシピマネージャーを初期化中", "message": "レシピを読み込んで処理中。数分かかる場合があります..." diff --git a/locales/ko.json b/locales/ko.json index 69eecc88..b32d16da 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Checkpoint 이름 복사", "copyEmbeddingName": "Embedding 이름 복사", "embeddingNameCopied": "Embedding 구문 복사됨", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "ComfyUI로 전송", "sendEmbeddingToWorkflow": "ComfyUI로 전송" }, @@ -233,6 +234,7 @@ "recipes": "레시피", "checkpoints": "Checkpoint", "embeddings": "Embedding", + "other": "[TODO: Translate] Other", "statistics": "통계" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding 모델" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "루트", "collapseAll": "모든 폴더 접기", @@ -1878,6 +1883,10 @@ "title": "Embedding Manager 초기화 중", "message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..." }, + "other": { + "title": "[TODO: Translate] Initializing Other Models Manager", + "message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "레시피 매니저 초기화 중", "message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..." diff --git a/locales/ru.json b/locales/ru.json index 2a19a262..bbdbd5a7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Копировать имя checkpoint", "copyEmbeddingName": "Копировать имя embedding", "embeddingNameCopied": "Синтаксис embedding скопирован", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "Отправить в ComfyUI", "sendEmbeddingToWorkflow": "Отправить в ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "Рецепты", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "Статистика" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Модели Embedding" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "Корень", "collapseAll": "Свернуть все папки", @@ -1878,6 +1883,10 @@ "title": "Инициализация Embedding Manager", "message": "Сканирование и построение кэша embedding. Это может занять несколько минут..." }, + "other": { + "title": "[TODO: Translate] Initializing Other Models Manager", + "message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "Инициализация менеджера рецептов", "message": "Загрузка и обработка рецептов. Это может занять несколько минут..." diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 29935f11..817bdd54 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -149,6 +149,7 @@ "copyCheckpointName": "复制 Checkpoint 名称", "copyEmbeddingName": "复制 Embedding 名称", "embeddingNameCopied": "已复制 Embedding 语法", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "发送到 ComfyUI", "sendEmbeddingToWorkflow": "发送到 ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "配方", "checkpoints": "Checkpoint", "embeddings": "Embedding", + "other": "[TODO: Translate] Other", "statistics": "统计" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding 模型" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "根目录", "collapseAll": "折叠所有文件夹", @@ -1878,6 +1883,10 @@ "title": "初始化 Embedding 管理器", "message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..." }, + "other": { + "title": "[TODO: Translate] Initializing Other Models Manager", + "message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "初始化配方管理器", "message": "正在加载和处理配方。这可能需要几分钟..." diff --git a/locales/zh-TW.json b/locales/zh-TW.json index dec6b87c..a79b4c45 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -149,6 +149,7 @@ "copyCheckpointName": "複製 Checkpoint 名稱", "copyEmbeddingName": "複製嵌入名稱", "embeddingNameCopied": "已複製 Embedding 語法", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "傳送到 ComfyUI", "sendEmbeddingToWorkflow": "傳送到 ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "配方", "checkpoints": "Checkpoint", "embeddings": "Embedding", + "other": "[TODO: Translate] Other", "statistics": "統計" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding 模型" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "根目錄", "collapseAll": "全部摺疊資料夾", @@ -1878,6 +1883,10 @@ "title": "初始化 Embedding 管理器", "message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..." }, + "other": { + "title": "[TODO: Translate] Initializing Other Models Manager", + "message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "初始化配方管理器", "message": "正在載入並處理配方,可能需要幾分鐘..." diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index dbecbab5..d1bce2cc 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -9,7 +9,8 @@ import { state } from '../state/index.js'; export const MODEL_TYPES = { LORA: 'loras', CHECKPOINT: 'checkpoints', - EMBEDDING: 'embeddings' // Future model type + EMBEDDING: 'embeddings', + OTHER: 'other' }; // Base API configuration for each model type @@ -40,6 +41,15 @@ export const MODEL_CONFIG = { supportsBulkOperations: true, supportsMove: true, templateName: 'embeddings.html' + }, + [MODEL_TYPES.OTHER]: { + displayName: 'Other Model', + singularName: 'other', + defaultPageSize: 100, + supportsLetterFilter: false, + supportsBulkOperations: true, + supportsMove: true, + templateName: 'other.html' } }; @@ -133,6 +143,9 @@ export const MODEL_SPECIFIC_ENDPOINTS = { }, [MODEL_TYPES.EMBEDDING]: { metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`, + }, + [MODEL_TYPES.OTHER]: { + metadata: `/api/lm/${MODEL_TYPES.OTHER}/metadata`, } }; diff --git a/static/js/api/modelApiFactory.js b/static/js/api/modelApiFactory.js index 154b103b..c5ef1469 100644 --- a/static/js/api/modelApiFactory.js +++ b/static/js/api/modelApiFactory.js @@ -1,6 +1,7 @@ import { LoraApiClient } from './loraApi.js'; import { CheckpointApiClient } from './checkpointApi.js'; import { EmbeddingApiClient } from './embeddingApi.js'; +import { OtherApiClient } from './otherApi.js'; import { MODEL_TYPES, isValidModelType } from './apiConfig.js'; import { state } from '../state/index.js'; @@ -12,6 +13,8 @@ export function createModelApiClient(modelType) { return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT); case MODEL_TYPES.EMBEDDING: return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING); + case MODEL_TYPES.OTHER: + return new OtherApiClient(MODEL_TYPES.OTHER); default: throw new Error(`Unsupported model type: ${modelType}`); } diff --git a/static/js/api/otherApi.js b/static/js/api/otherApi.js new file mode 100644 index 00000000..eecf72d2 --- /dev/null +++ b/static/js/api/otherApi.js @@ -0,0 +1,7 @@ +import { BaseModelApiClient } from './baseModelApi.js'; + +/** + * Other-models-specific API client (VAE, upscalers, text encoders, etc.) + */ +export class OtherApiClient extends BaseModelApiClient { +} diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index b9194a66..1ba8be57 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -139,8 +139,8 @@ export class BulkContextMenu extends BaseContextMenu { const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]'); if (downloadExampleImagesSubmenu) { - // Show on model pages (loras, checkpoints, embeddings), hide on recipes - downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none'; + // Show on model pages (loras, checkpoints, embeddings, other), hide on recipes + downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings', 'other'].includes(currentModelType) ? 'flex' : 'none'; } const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]'); diff --git a/static/js/components/ContextMenu/ModelContextMenuMixin.js b/static/js/components/ContextMenu/ModelContextMenuMixin.js index 42a116c7..4fcf96c8 100644 --- a/static/js/components/ContextMenu/ModelContextMenuMixin.js +++ b/static/js/components/ContextMenu/ModelContextMenuMixin.js @@ -112,7 +112,8 @@ export const ModelContextMenuMixin = { const prefixMap = { lora: 'loras', checkpoint: 'checkpoints', - embedding: 'embeddings' + embedding: 'embeddings', + other: 'other' }; return prefixMap[this.modelType] || 'loras'; }, diff --git a/static/js/components/ContextMenu/OtherContextMenu.js b/static/js/components/ContextMenu/OtherContextMenu.js new file mode 100644 index 00000000..b51cad6e --- /dev/null +++ b/static/js/components/ContextMenu/OtherContextMenu.js @@ -0,0 +1,72 @@ +import { BaseContextMenu } from './BaseContextMenu.js'; +import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; +import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'; +import { moveManager } from '../../managers/MoveManager.js'; +import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; + +export class OtherContextMenu extends BaseContextMenu { + constructor() { + super('otherContextMenu', '.model-card'); + this.nsfwSelector = document.getElementById('nsfwLevelSelector'); + this.modelType = 'other'; + this.resetAndReload = resetAndReload; + + this.initNSFWSelector(); + } + + // Implementation needed by the mixin + async saveModelMetadata(filePath, data) { + return getModelApiClient().saveModelMetadata(filePath, data); + } + + showMenu(x, y, card) { + super.showMenu(x, y, card); + this.updateExcludeMenuItem(); + } + + handleMenuAction(action) { + // First try to handle with common actions + if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { + return; + } + + const apiClient = getModelApiClient(); + + // Otherwise handle other-models-specific actions + switch(action) { + case 'details': + // Show model details + this.currentCard.click(); + break; + case 'replace-preview': + // Add new action for replacing preview images + apiClient.replaceModelPreview(this.currentCard.dataset.filepath); + break; + case 'delete': + showDeleteModal(this.currentCard.dataset.filepath); + break; + case 'copyname': + // Copy model name + if (this.currentCard.querySelector('.fa-copy')) { + this.currentCard.querySelector('.fa-copy').click(); + } + break; + case 'refresh-metadata': + // Refresh metadata from CivitAI + apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); + break; + case 'move': + moveManager.showMoveModal(this.currentCard.dataset.filepath); + break; + case 'exclude': + showExcludeModal(this.currentCard.dataset.filepath); + break; + case 'restore': + this.restoreExcludedModel(this.currentCard.dataset.filepath); + break; + } + } +} + +// Mix in shared methods +Object.assign(OtherContextMenu.prototype, ModelContextMenuMixin); diff --git a/static/js/components/ContextMenu/index.js b/static/js/components/ContextMenu/index.js index 7d01c0f6..a629cd4e 100644 --- a/static/js/components/ContextMenu/index.js +++ b/static/js/components/ContextMenu/index.js @@ -2,6 +2,7 @@ export { LoraContextMenu } from './LoraContextMenu.js'; export { RecipeContextMenu } from './RecipeContextMenu.js'; export { CheckpointContextMenu } from './CheckpointContextMenu.js'; export { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; +export { OtherContextMenu } from './OtherContextMenu.js'; export { GlobalContextMenu } from './GlobalContextMenu.js'; export { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; @@ -9,6 +10,7 @@ import { LoraContextMenu } from './LoraContextMenu.js'; import { RecipeContextMenu } from './RecipeContextMenu.js'; import { CheckpointContextMenu } from './CheckpointContextMenu.js'; import { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; +import { OtherContextMenu } from './OtherContextMenu.js'; import { GlobalContextMenu } from './GlobalContextMenu.js'; // Factory method to create page-specific context menu instances @@ -22,6 +24,8 @@ export function createPageContextMenu(pageType) { return new CheckpointContextMenu(); case 'embeddings': return new EmbeddingContextMenu(); + case 'other': + return new OtherContextMenu(); default: return null; } diff --git a/static/js/components/Header.js b/static/js/components/Header.js index 835bf560..56133956 100644 --- a/static/js/components/Header.js +++ b/static/js/components/Header.js @@ -32,6 +32,7 @@ export class HeaderManager { if (path.includes('/loras/recipes')) return 'recipes'; if (path.includes('/checkpoints')) return 'checkpoints'; if (path.includes('/embeddings')) return 'embeddings'; + if (path.includes('/other')) return 'other'; if (path.includes('/statistics')) return 'statistics'; if (path.includes('/loras')) return 'loras'; return 'unknown'; diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index e2d12e70..8c127be6 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -1126,6 +1126,7 @@ export class SidebarManager { recipes: 'Recipes', checkpoints: 'Checkpoints', embeddings: 'Embeddings', + other: 'Other Models', }; return names[this.pageType] || this.pageType; } @@ -1804,7 +1805,7 @@ export class SidebarManager { _migrateOldSettings() { if (getStorageItem('_sidebar_migration_done')) return; - const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings']; + const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other']; // 1. Migrate global hide setting to per-page if (state?.global?.settings?.show_folder_sidebar === false) { diff --git a/static/js/components/controls/OtherControls.js b/static/js/components/controls/OtherControls.js new file mode 100644 index 00000000..e7a19388 --- /dev/null +++ b/static/js/components/controls/OtherControls.js @@ -0,0 +1,60 @@ +// OtherControls.js - Specific implementation for the Other Models page +import { PageControls } from './PageControls.js'; +import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'; +import { showToast } from '../../utils/uiHelpers.js'; + +/** + * OtherControls class - Extends PageControls for the Other Models page + * (VAE, upscalers, text encoders, CLIP vision, ControlNet, ...) + */ +export class OtherControls extends PageControls { + constructor() { + // Initialize with 'other' page type + super('other'); + + // Register API methods specific to the Other Models page + this.registerOtherAPI(); + } + + /** + * Register Other-models-specific API methods + */ + registerOtherAPI() { + const otherAPI = { + // Core API functions + loadMoreModels: async (resetPage = false, updateFolders = false) => { + return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders); + }, + + resetAndReload: async (updateFolders = false) => { + return await resetAndReload(updateFolders); + }, + + refreshModels: async (fullRebuild = false) => { + return await getModelApiClient().refreshModels(fullRebuild); + }, + + // Add fetch from Civitai functionality for other models + fetchFromCivitai: async () => { + return await getModelApiClient().fetchCivitaiMetadata(); + }, + + toggleBulkMode: () => { + if (window.bulkManager) { + window.bulkManager.toggleBulkMode(); + } else { + console.error('Bulk manager not available'); + } + }, + + // No clearCustomFilter implementation is needed for other models + // as custom filters are currently only used for LoRAs + clearCustomFilter: async () => { + showToast('toast.filters.noCustomFilterToClear', {}, 'info'); + } + }; + + // Register the API + this.registerAPI(otherAPI); + } +} diff --git a/static/js/components/controls/index.js b/static/js/components/controls/index.js index 97f1ca91..5d1dcaa6 100644 --- a/static/js/components/controls/index.js +++ b/static/js/components/controls/index.js @@ -3,13 +3,14 @@ import { PageControls } from './PageControls.js'; import { LorasControls } from './LorasControls.js'; import { CheckpointsControls } from './CheckpointsControls.js'; import { EmbeddingsControls } from './EmbeddingsControls.js'; +import { OtherControls } from './OtherControls.js'; // Export the classes -export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls }; +export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls, OtherControls }; /** * Factory function to create the appropriate controls based on page type - * @param {string} pageType - The type of page ('loras', 'checkpoints', or 'embeddings') + * @param {string} pageType - The type of page ('loras', 'checkpoints', 'embeddings', or 'other') * @returns {PageControls} - The appropriate controls instance */ export function createPageControls(pageType) { @@ -19,8 +20,10 @@ export function createPageControls(pageType) { return new CheckpointsControls(); } else if (pageType === 'embeddings') { return new EmbeddingsControls(); + } else if (pageType === 'other') { + return new OtherControls(); } else { console.error(`Unknown page type: ${pageType}`); return null; } -} \ No newline at end of file +} diff --git a/static/js/components/initialization.js b/static/js/components/initialization.js index 00a9409a..209b5311 100644 --- a/static/js/components/initialization.js +++ b/static/js/components/initialization.js @@ -58,6 +58,8 @@ class InitializationManager { this.pageType = 'recipes'; } else if (path.includes('/checkpoints')) { this.pageType = 'checkpoints'; + } else if (path.includes('/other')) { + this.pageType = 'other'; } else if (path.includes('/loras')) { this.pageType = 'loras'; } else if (path.includes('/embeddings')) { @@ -221,6 +223,7 @@ class InitializationManager { 'lora': 'loras', 'checkpoint': 'checkpoints', 'embedding': 'embeddings', + 'other': 'other', 'recipe': 'recipes' }; diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index fd0f9a20..67dcee31 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -250,6 +250,11 @@ function handleCopyAction(card, modelType) { const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`; const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied'); copyToClipboard(embeddingCode, message); + } else { + // Other model types (VAE, upscalers, ...) - copy the file name + const fileName = card.dataset.file_name; + const message = translate('modelCard.actions.modelNameCopied', {}, 'Model name copied'); + copyToClipboard(fileName, message); } } diff --git a/static/js/core.js b/static/js/core.js index c9943bff..866a136a 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -116,7 +116,7 @@ export class AppCore { initializePageFeatures() { const pageType = this.getPageType(); - if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) { + if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other'].includes(pageType)) { this.initializeContextMenus(pageType); initializeInfiniteScroll(pageType); } diff --git a/static/js/managers/BannerService.js b/static/js/managers/BannerService.js index fe837aca..5b4b4a34 100644 --- a/static/js/managers/BannerService.js +++ b/static/js/managers/BannerService.js @@ -424,12 +424,13 @@ class BannerService { /** * Get the current page type from the URL - * @returns {string} Page type (loras, checkpoints, embeddings, recipes) + * @returns {string} Page type (loras, checkpoints, embeddings, other, recipes) */ getCurrentPageType() { const path = window.location.pathname; if (path.includes('/checkpoints')) return 'checkpoints'; if (path.includes('/embeddings')) return 'embeddings'; + if (path.includes('/other')) return 'other'; if (path.includes('/recipes')) return 'recipes'; return 'loras'; } @@ -443,7 +444,8 @@ class BannerService { const endpoints = { 'loras': '/api/lm/loras/reload?rebuild=true', 'checkpoints': '/api/lm/checkpoints/reload?rebuild=true', - 'embeddings': '/api/lm/embeddings/reload?rebuild=true' + 'embeddings': '/api/lm/embeddings/reload?rebuild=true', + 'other': '/api/lm/other/reload?rebuild=true' }; return endpoints[pageType] || endpoints['loras']; } diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index c7bee250..284128c8 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -93,6 +93,20 @@ export class BulkManager { setFavorite: true, unfavorite: true }, + [MODEL_TYPES.OTHER]: { + addTags: true, + sendToWorkflow: false, + copyAll: false, + refreshAll: true, + checkUpdates: true, + moveAll: true, + autoOrganize: true, + deleteAll: true, + setContentRating: true, + skipMetadataRefresh: true, + setFavorite: true, + unfavorite: true + }, recipes: { addTags: true, sendToWorkflow: false, diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 218bbfb9..76640f33 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -805,7 +805,7 @@ export class FilterManager { // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { await window.recipeManager.loadRecipes(true); - } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { + } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') { // For models page, reset the page and reload await getModelApiClient().loadMoreWithVirtualScroll(true, false); } @@ -904,7 +904,7 @@ export class FilterManager { // Reload data using the appropriate method for the current page if (this.currentPage === 'recipes' && window.recipeManager) { await window.recipeManager.loadRecipes(true); - } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { + } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') { await getModelApiClient().loadMoreWithVirtualScroll(true, true); } diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index da1656ca..d5042415 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -60,7 +60,6 @@ class MoveManager { this.bulkFilePaths = null; const apiClient = this._getApiClient(modelType); - const currentPageType = state.currentPageType; const modelConfig = apiClient.apiConfig.config; // Handle bulk mode @@ -113,7 +112,7 @@ class MoveManager { ).join(''); // Set default root if available - const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; + const settingsKey = `default_${modelConfig.singularName}_root`; const defaultRoot = state.global.settings[settingsKey]; if (defaultRoot && rootsData.roots.includes(defaultRoot)) { modelRootSelect.value = defaultRoot; @@ -228,13 +227,12 @@ class MoveManager { if (modelRoot) { if (this.useDefaultPath) { // Show actual template path - try { - const singularType = apiClient.modelType.replace(/s$/, ''); - const templates = state.global.settings.download_path_templates; - const template = templates[singularType]; + const singularType = config.singularName || apiClient.modelType.replace(/s$/, ''); + const templates = state.global.settings.download_path_templates; + const template = templates[singularType]; + if (template) { fullPath += `/${template}`; - } catch (error) { - console.error('Failed to fetch template:', error); + } else { fullPath += '/' + translate('modals.download.autoOrganizedPath'); } } else { diff --git a/static/js/managers/SearchManager.js b/static/js/managers/SearchManager.js index b4612f20..c610618d 100644 --- a/static/js/managers/SearchManager.js +++ b/static/js/managers/SearchManager.js @@ -298,7 +298,7 @@ export class SearchManager { pageState.searchOptions.loraName = options.loraName || false; pageState.searchOptions.loraModel = options.loraModel || false; pageState.searchOptions.prompt = options.prompt || false; - } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { + } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') { // Update only the relevant fields in searchOptions instead of replacing the whole object pageState.searchOptions.filename = options.filename || false; pageState.searchOptions.modelname = options.modelname || false; @@ -311,7 +311,7 @@ export class SearchManager { // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { window.recipeManager.loadRecipes(true); - } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { + } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') { // For models page, reset the page and reload getModelApiClient().loadMoreWithVirtualScroll(true, false); } diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index fa2239bb..ec5f2dc9 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -3360,6 +3360,9 @@ export class SettingsManager { } else if (this.currentPage === 'embeddings') { // Reload the embeddings without updating folders await resetAndReload(false); + } else if (this.currentPage === 'other') { + // Reload the other models without updating folders + await resetAndReload(false); } } diff --git a/static/js/other.js b/static/js/other.js new file mode 100644 index 00000000..401ac7e0 --- /dev/null +++ b/static/js/other.js @@ -0,0 +1,57 @@ +import { appCore } from './core.js'; +import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; +import { createPageControls } from './components/controls/index.js'; +import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; +import { MODEL_TYPES } from './api/apiConfig.js'; +import { initActiveFiltersSync } from './utils/activeFiltersSync.js'; + +// Initialize the Other Models page +class OtherPageManager { + constructor() { + // Initialize page controls + this.pageControls = createPageControls(MODEL_TYPES.OTHER); + + // Initialize the ModelDuplicatesManager + this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.OTHER); + + // Expose only necessary functions to global scope + this._exposeRequiredGlobalFunctions(); + } + + _exposeRequiredGlobalFunctions() { + // Minimal set of functions that need to remain global + window.confirmDelete = confirmDelete; + window.closeDeleteModal = closeDeleteModal; + window.confirmExclude = confirmExclude; + window.closeExcludeModal = closeExcludeModal; + + // Expose duplicates manager + window.modelDuplicatesManager = this.duplicatesManager; + } + + async initialize() { + // Initialize common page features (including context menus) + appCore.initializePageFeatures(); + + // Mirror active filters to the backend for the ComfyUI-side autocomplete + initActiveFiltersSync(MODEL_TYPES.OTHER); + + console.log('Other Models Manager initialized'); + } +} + +async function initializeOtherPage() { + // Initialize core application + await appCore.initialize(); + + // Initialize other models page + const otherPage = new OtherPageManager(); + await otherPage.initialize(); + + return otherPage; +} + +// Initialize everything when DOM is ready +document.addEventListener('DOMContentLoaded', initializeOtherPage); + +export { OtherPageManager, initializeOtherPage }; diff --git a/static/js/state/index.js b/static/js/state/index.js index af73d4c9..e20b82d4 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -79,6 +79,7 @@ export function createDefaultSettings() { const loraPreviewVersions = getMapFromStorage('loras_preview_versions'); const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions'); const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions'); +const otherPreviewVersions = getMapFromStorage('other_preview_versions'); export const state = { // Global state @@ -234,6 +235,44 @@ export const state = { search: '', }, activeViewSnapshot: null, + }, + + [MODEL_TYPES.OTHER]: { + currentPage: 1, + isLoading: false, + hasMore: true, + sortBy: 'name', + activeFolder: getStorageItem(`${MODEL_TYPES.OTHER}_activeFolder`), + previewVersions: otherPreviewVersions, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + tags: false, + creator: false, + hash: false, + recursive: getStorageItem(`${MODEL_TYPES.OTHER}_recursiveSearch`, true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [], + search: '', + tagLogic: 'any', + }, + bulkMode: false, + selectedModels: new Set(), + metadataCache: new Map(), + showFavoritesOnly: false, + showUpdateAvailableOnly: false, + duplicatesMode: false, + viewMode: 'active', + excludedViewState: { + sortBy: 'name:asc', + search: '', + }, + activeViewSnapshot: null, } }, diff --git a/static/js/utils/activeFiltersSync.js b/static/js/utils/activeFiltersSync.js index 1500f2d4..51e45ca3 100644 --- a/static/js/utils/activeFiltersSync.js +++ b/static/js/utils/activeFiltersSync.js @@ -53,7 +53,7 @@ export function syncActiveFilters(pageType) { * Register the storage listener and push the current (restored) state once. * The initial push covers server restarts, where the backend store is empty * until the manager page re-publishes its localStorage-restored filters. - * @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings' + * @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings' | 'other' */ export function initActiveFiltersSync(pageType) { setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType)); diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index f29f7522..412ac647 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -106,6 +106,12 @@ export const MODEL_SUBTYPE_DISPLAY_NAMES = { diffusion_model: "Diffusion Model", // Embedding sub-types embedding: "Embedding", + // Other model sub-types + vae: "VAE", + upscaler: "Upscaler", + text_encoder: "Text Encoder", + clip_vision: "CLIP Vision", + controlnet: "ControlNet", }; // Backward compatibility alias @@ -119,6 +125,11 @@ export const MODEL_SUBTYPE_ABBREVIATIONS = { checkpoint: "CKPT", diffusion_model: "DM", embedding: "EMB", + vae: "VAE", + upscaler: "UPS", + text_encoder: "TE", + clip_vision: "CV", + controlnet: "CN", }; export function getSubTypeAbbreviation(subType) { diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 9e834f29..138bceb4 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -66,7 +66,7 @@ async function getCardCreator(pageType) { // Function to get the appropriate data fetcher based on page type async function getDataFetcher(pageType) { - if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints') { + if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints' || pageType === 'other') { return (page = 1, pageSize = 100) => getModelApiClient().fetchModelsPage(page, pageSize); } else if (pageType === 'recipes') { // Import the recipeApi module and use the fetchRecipesPage function diff --git a/static/js/utils/storageHelpers.js b/static/js/utils/storageHelpers.js index 4250605a..82a7df88 100644 --- a/static/js/utils/storageHelpers.js +++ b/static/js/utils/storageHelpers.js @@ -8,7 +8,7 @@ const STORAGE_PREFIX = 'lora_manager_'; // Matches keys that carry the manager page's active filter state // (e.g. 'loras_activeFolder', 'checkpoints_filters'). -const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings)_(activeFolder|recursiveSearch|filters)$/; +const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings|other)_(activeFolder|recursiveSearch|filters)$/; let activeFiltersListener = null; diff --git a/templates/components/controls.html b/templates/components/controls.html index 49532bd2..ef83941b 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -36,7 +36,7 @@ {% endif %} - {% if page_id != 'embeddings' and page_id != 'recipes' %} + {% if page_id != 'embeddings' and page_id != 'recipes' and page_id != 'other' %} @@ -80,12 +80,14 @@
+ {% if page_id != 'other' %}
{% endif %} + {% endif %} {% if page_id == 'recipes' %}
diff --git a/templates/components/header.html b/templates/components/header.html index 9785e4bb..d55f328c 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -15,6 +15,8 @@ {% set current_page = 'checkpoints' %} {% elif current_path.startswith('/embeddings') %} {% set current_page = 'embeddings' %} + {% elif current_path.startswith('/other') %} + {% set current_page = 'other' %} {% elif current_path.startswith('/statistics') %} {% set current_page = 'statistics' %} {% else %} @@ -36,6 +38,10 @@ id="embeddingsNavItem"> {{ t('header.navigation.embeddings') }} + + {{ t('header.navigation.other') }} + {{ t('header.navigation.statistics') }} @@ -208,7 +214,7 @@
{{ t('header.search.filters.tags') }}
{{ t('header.search.filters.creator') }}
{{ t('header.search.filters.hash') }}
- {% elif request.path == '/embeddings' %} + {% elif request.path == '/embeddings' or request.path == '/other' %}
{{ t('header.search.filters.filename') }}
{{ t('header.search.filters.modelname') }}
{{ t('header.search.filters.tags') }}
@@ -295,7 +301,7 @@
{% endif %} - {% if current_page == 'loras' or current_page == 'checkpoints' %} + {% if current_page == 'loras' or current_page == 'checkpoints' or current_page == 'other' %}

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

diff --git a/templates/other.html b/templates/other.html new file mode 100644 index 00000000..46f25668 --- /dev/null +++ b/templates/other.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}{{ t('other.title') }}{% endblock %} +{% block page_id %}other{% endblock %} + +{% block init_title %}{{ t('initialization.other.title') }}{% endblock %} +{% block init_message %}{{ t('initialization.other.message') }}{% endblock %} +{% block init_check_url %}/api/other/list?page=1&page_size=1{% endblock %} + +{% block additional_components %} + + +{% endblock %} + +{% block content %} +
+ {% include 'components/controls.html' %} + {% include 'components/breadcrumb.html' %} +
+ {% include 'components/duplicates_banner.html' %} + {% include 'components/folder_sidebar.html' %} + + +
+ +
+{% endblock %} + +{% block overlay %} +
+{% endblock %} + +{% block main_script %} + +{% endblock %} diff --git a/tests/frontend/api/otherApi.test.js b/tests/frontend/api/otherApi.test.js new file mode 100644 index 00000000..c8293bf4 --- /dev/null +++ b/tests/frontend/api/otherApi.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +// Import order matters: api modules are circularly dependent +// (modelApiFactory -> loraApi -> baseModelApi -> modelApiFactory/state). +// Loading the factory first lets baseModelApi fully evaluate before the +// client subclasses extend it. +import { createModelApiClient, getModelApiClient } from '../../../static/js/api/modelApiFactory.js'; +import { + MODEL_TYPES, + MODEL_CONFIG, + getApiEndpoints, + getCompleteApiConfig, + isValidModelType, +} from '../../../static/js/api/apiConfig.js'; +import { OtherApiClient } from '../../../static/js/api/otherApi.js'; + +describe('apiConfig - other model type', () => { + it('exposes OTHER model type', () => { + expect(MODEL_TYPES.OTHER).toBe('other'); + expect(isValidModelType('other')).toBe(true); + }); + + it('has a complete MODEL_CONFIG entry', () => { + const config = MODEL_CONFIG[MODEL_TYPES.OTHER]; + + expect(config).toBeDefined(); + expect(config.singularName).toBe('other'); + expect(config.supportsLetterFilter).toBe(false); + expect(config.supportsBulkOperations).toBe(true); + expect(config.supportsMove).toBe(true); + expect(config.templateName).toBe('other.html'); + }); + + it('generates /api/lm/other/* endpoints', () => { + const endpoints = getApiEndpoints('other'); + + expect(endpoints.list).toBe('/api/lm/other/list'); + expect(endpoints.delete).toBe('/api/lm/other/delete'); + expect(endpoints.exclude).toBe('/api/lm/other/exclude'); + expect(endpoints.unexclude).toBe('/api/lm/other/unexclude'); + expect(endpoints.rename).toBe('/api/lm/other/rename'); + expect(endpoints.save).toBe('/api/lm/other/save-metadata'); + expect(endpoints.bulkDelete).toBe('/api/lm/other/bulk-delete'); + expect(endpoints.moveModel).toBe('/api/lm/other/move_model'); + expect(endpoints.moveBulk).toBe('/api/lm/other/move_models_bulk'); + expect(endpoints.fetchCivitai).toBe('/api/lm/other/fetch-civitai'); + expect(endpoints.fetchAllCivitai).toBe('/api/lm/other/fetch-all-civitai'); + expect(endpoints.scan).toBe('/api/lm/other/scan'); + expect(endpoints.topTags).toBe('/api/lm/other/top-tags'); + expect(endpoints.baseModels).toBe('/api/lm/other/base-models'); + expect(endpoints.roots).toBe('/api/lm/other/roots'); + expect(endpoints.folders).toBe('/api/lm/other/folders'); + expect(endpoints.duplicates).toBe('/api/lm/other/find-duplicates'); + expect(endpoints.replacePreview).toBe('/api/lm/other/replace-preview'); + }); + + it('merges other-specific endpoints into the complete config', () => { + const config = getCompleteApiConfig('other'); + + expect(config.modelType).toBe('other'); + expect(config.config).toBe(MODEL_CONFIG.other); + expect(config.endpoints.specific.metadata).toBe('/api/lm/other/metadata'); + }); +}); + +describe('modelApiFactory - other model type', () => { + it('creates an OtherApiClient for the other model type', () => { + const client = createModelApiClient(MODEL_TYPES.OTHER); + + expect(client).toBeInstanceOf(OtherApiClient); + expect(client.modelType).toBe('other'); + expect(client.apiConfig.endpoints.list).toBe('/api/lm/other/list'); + }); + + it('returns a cached singleton from getModelApiClient', () => { + const first = getModelApiClient(MODEL_TYPES.OTHER); + const second = getModelApiClient(MODEL_TYPES.OTHER); + + expect(first).toBeInstanceOf(OtherApiClient); + expect(second).toBe(first); + }); + + it('still rejects unsupported model types', () => { + expect(() => createModelApiClient('bogus')).toThrow('Unsupported model type: bogus'); + }); +}); diff --git a/tests/frontend/components/modelCard.subTypeBadge.test.js b/tests/frontend/components/modelCard.subTypeBadge.test.js new file mode 100644 index 00000000..a1bea917 --- /dev/null +++ b/tests/frontend/components/modelCard.subTypeBadge.test.js @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + MODEL_CARD_MODULE, + STATE_MODULE, + UI_HELPERS_MODULE, + I18N_MODULE, + API_CONFIG_MODULE, + API_FACTORY_MODULE, +} = vi.hoisted(() => ({ + MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.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, + 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, +})); + +vi.mock(STATE_MODULE, () => ({ + state: { + settings: { + blur_mature_content: false, + model_name_display: 'model_name', + }, + global: { + settings: { + model_name_display: 'model_name', + group_by_model: false, + display_density: 'default', + model_card_footer_action: 'example_images', + }, + }, + pages: { + other: { + previewVersions: new Map(), + sortBy: 'name', + }, + }, + bulkMode: false, + selectedModels: new Set(), + selectedLoras: new Set(), + }, + getCurrentPageState: vi.fn(() => ({ + sortBy: 'name', + previewVersions: new Map(), + })), +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), + openCivitai: vi.fn(), + openHuggingFace: vi.fn(), + copyToClipboard: vi.fn(), + copyLoraSyntax: vi.fn(), + sendLoraToWorkflow: vi.fn(), + sendEmbeddingToWorkflow: vi.fn(), + openExampleImagesFolder: vi.fn(), + buildLoraSyntax: vi.fn(), + sendModelPathToWorkflow: vi.fn(), +})); + +vi.mock(I18N_MODULE, () => ({ + translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)), +})); + +vi.mock(API_CONFIG_MODULE, () => ({ + MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings', OTHER: 'other' }, +})); + +vi.mock(API_FACTORY_MODULE, () => ({ + getModelApiClient: vi.fn(() => ({})), +})); + +function createOtherModel(overrides = {}) { + return { + sha256: 'abc123', + file_path: '/models/vae/test_vae.safetensors', + model_name: 'Test VAE', + file_name: 'test_vae', + folder: 'vae', + modified: 1234567890, + file_size: 1024, + notes: '', + base_model: '', + favorite: false, + exclude: false, + hf_url: '', + update_available: false, + skip_metadata_refresh: false, + preview_url: '', + preview_nsfw_level: 0, + tags: [], + civitai: {}, + sub_type: 'vae', + ...overrides, + }; +} + +describe('ModelCard sub-type badges for other model types', () => { + let createModelCard; + + beforeEach(async () => { + ({ createModelCard } = await import(MODEL_CARD_MODULE)); + }); + + it.each([ + ['vae', 'VAE', 'VAE'], + ['upscaler', 'UPS', 'Upscaler'], + ['text_encoder', 'TE', 'Text Encoder'], + ['clip_vision', 'CV', 'CLIP Vision'], + ['controlnet', 'CN', 'ControlNet'], + ])('renders the %s badge abbreviation and tooltip', (subType, abbreviation, displayName) => { + const card = createModelCard(createOtherModel({ sub_type: subType }), 'other'); + + const badge = card.querySelector('.model-sub-type'); + expect(badge).not.toBeNull(); + expect(badge.textContent).toBe(abbreviation); + + const label = card.querySelector('.base-model-label'); + expect(label.getAttribute('title')).toContain(displayName); + }); + + it('stores sub_type on the card dataset', () => { + const card = createModelCard(createOtherModel({ sub_type: 'text_encoder' }), 'other'); + + expect(card.dataset.sub_type).toBe('text_encoder'); + }); +}); diff --git a/tests/frontend/components/otherFactories.test.js b/tests/frontend/components/otherFactories.test.js new file mode 100644 index 00000000..39cd0983 --- /dev/null +++ b/tests/frontend/components/otherFactories.test.js @@ -0,0 +1,159 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + getModelApiClientMock, + resetAndReloadMock, + showToastMock, + sidebarManagerMock, + moveManagerMock, + showDeleteModalMock, + showExcludeModalMock, +} = vi.hoisted(() => ({ + getModelApiClientMock: vi.fn(), + resetAndReloadMock: vi.fn(async () => {}), + showToastMock: vi.fn(), + sidebarManagerMock: { + setHostPageControls: vi.fn(), + initialize: vi.fn(async function () { + sidebarManagerMock.isInitialized = true; + }), + refresh: vi.fn(async () => {}), + cleanup: vi.fn(), + isInitialized: false, + }, + moveManagerMock: { + showMoveModal: vi.fn(), + }, + showDeleteModalMock: vi.fn(), + showExcludeModalMock: vi.fn(), +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + getModelApiClient: getModelApiClientMock, + resetAndReload: resetAndReloadMock, +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + openCivitaiByMetadata: vi.fn(), + isTypingContext: () => false, + getNSFWLevelName: vi.fn(() => 'Unknown'), + openExampleImagesFolder: vi.fn(), +})); + +vi.mock('../../../static/js/managers/DownloadManager.js', () => ({ + downloadManager: { showDownloadModal: vi.fn() }, +})); + +vi.mock('../../../static/js/components/SidebarManager.js', () => ({ + sidebarManager: sidebarManagerMock, +})); + +vi.mock('../../../static/js/managers/MoveManager.js', () => ({ + moveManager: moveManagerMock, +})); + +vi.mock('../../../static/js/utils/modalUtils.js', () => ({ + showDeleteModal: showDeleteModalMock, + showExcludeModal: showExcludeModalMock, +})); + +vi.mock('../../../static/js/components/alphabet/index.js', () => ({ + createAlphabetBar: vi.fn(() => ({ destroy: vi.fn() })), +})); + +vi.mock('../../../static/js/utils/updateCheckHelpers.js', () => ({ + performModelUpdateCheck: vi.fn(async () => ({ status: 'success', displayName: 'Model', records: [] })), +})); + +import { createPageControls } from '../../../static/js/components/controls/index.js'; +import { OtherControls } from '../../../static/js/components/controls/OtherControls.js'; +import { createPageContextMenu } from '../../../static/js/components/ContextMenu/index.js'; +import { OtherContextMenu } from '../../../static/js/components/ContextMenu/OtherContextMenu.js'; + +describe('createPageControls', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + sessionStorage.clear(); + document.body.innerHTML = ''; + document.body.dataset.page = 'other'; + sidebarManagerMock.isInitialized = false; + }); + + afterEach(() => { + delete window.pageControls; + delete window.bulkManager; + }); + + it('creates OtherControls for the other page type', () => { + const controls = createPageControls('other'); + + expect(controls).toBeInstanceOf(OtherControls); + expect(controls.pageType).toBe('other'); + // OtherControls registers its API with the base class + expect(typeof controls.api.loadMoreModels).toBe('function'); + expect(typeof controls.api.refreshModels).toBe('function'); + expect(typeof controls.api.fetchFromCivitai).toBe('function'); + expect(typeof controls.api.toggleBulkMode).toBe('function'); + }); + + it('returns null for an unknown page type', () => { + expect(createPageControls('not-a-page')).toBeNull(); + }); +}); + +describe('createPageContextMenu', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + function createMenuWithCard() { + const menu = createPageContextMenu('other'); + const card = document.createElement('div'); + card.className = 'model-card'; + card.dataset.filepath = '/models/vae/test.safetensors'; + document.body.appendChild(card); + menu.currentCard = card; + return { menu, card }; + } + + it('creates OtherContextMenu for the other page type', () => { + const menu = createPageContextMenu('other'); + + expect(menu).toBeInstanceOf(OtherContextMenu); + expect(menu.modelType).toBe('other'); + expect(menu.menu).toBe(document.getElementById('otherContextMenu')); + }); + + it('returns null for an unknown page type', () => { + expect(createPageContextMenu('not-a-page')).toBeNull(); + }); + + it('delegates refresh-metadata to the model API client', () => { + const refreshSingleModelMetadata = vi.fn(); + getModelApiClientMock.mockReturnValue({ refreshSingleModelMetadata }); + const { menu } = createMenuWithCard(); + + menu.handleMenuAction('refresh-metadata'); + + expect(refreshSingleModelMetadata).toHaveBeenCalledWith('/models/vae/test.safetensors'); + }); + + it('opens the move modal for the move action', () => { + const { menu } = createMenuWithCard(); + + menu.handleMenuAction('move'); + + expect(moveManagerMock.showMoveModal).toHaveBeenCalledWith('/models/vae/test.safetensors'); + }); + + it('shows the exclude modal for the exclude action', () => { + const { menu } = createMenuWithCard(); + + menu.handleMenuAction('exclude'); + + expect(showExcludeModalMock).toHaveBeenCalledWith('/models/vae/test.safetensors'); + }); +}); diff --git a/tests/frontend/core/appCore.test.js b/tests/frontend/core/appCore.test.js index 71cdae9e..d7d54bcd 100644 --- a/tests/frontend/core/appCore.test.js +++ b/tests/frontend/core/appCore.test.js @@ -120,7 +120,7 @@ import { initializeEventManagement } from '../../../static/js/utils/eventManagem import { initializeInfiniteScroll } from '../../../static/js/utils/infiniteScroll.js'; import { createPageContextMenu, createGlobalContextMenu } from '../../../static/js/components/ContextMenu/index.js'; -const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings']; +const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other']; describe('AppCore page orchestration', () => { beforeEach(() => { diff --git a/tests/frontend/pages/otherPage.test.js b/tests/frontend/pages/otherPage.test.js new file mode 100644 index 00000000..b39005c5 --- /dev/null +++ b/tests/frontend/pages/otherPage.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderOtherPage } from '../utils/pageFixtures.js'; + +const initializeAppMock = vi.fn(); +const initializePageFeaturesMock = vi.fn(); +const createPageControlsMock = vi.fn(); +const confirmDeleteMock = vi.fn(); +const closeDeleteModalMock = vi.fn(); +const confirmExcludeMock = vi.fn(); +const closeExcludeModalMock = vi.fn(); +const duplicatesManagerMock = vi.fn(); +const initActiveFiltersSyncMock = vi.fn(); + +vi.mock('../../../static/js/core.js', () => ({ + appCore: { + initialize: initializeAppMock, + initializePageFeatures: initializePageFeaturesMock, + }, +})); + +vi.mock('../../../static/js/components/controls/index.js', () => ({ + createPageControls: createPageControlsMock, +})); + +vi.mock('../../../static/js/utils/modalUtils.js', () => ({ + confirmDelete: confirmDeleteMock, + closeDeleteModal: closeDeleteModalMock, + confirmExclude: confirmExcludeMock, + closeExcludeModal: closeExcludeModalMock, +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { + OTHER: 'other', + }, +})); + +vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({ + ModelDuplicatesManager: duplicatesManagerMock, +})); + +vi.mock('../../../static/js/utils/activeFiltersSync.js', () => ({ + initActiveFiltersSync: initActiveFiltersSyncMock, +})); + +describe('OtherPageManager', () => { + let OtherPageManager; + let initializeOtherPage; + let duplicatesManagerInstance; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + duplicatesManagerInstance = { + checkDuplicatesCount: vi.fn(), + }; + + duplicatesManagerMock.mockReturnValue(duplicatesManagerInstance); + createPageControlsMock.mockReturnValue({ destroy: vi.fn() }); + initializeAppMock.mockResolvedValue(undefined); + + renderOtherPage(); + + ({ OtherPageManager, initializeOtherPage } = await import('../../../static/js/other.js')); + }); + + afterEach(() => { + delete window.confirmDelete; + delete window.closeDeleteModal; + delete window.confirmExclude; + delete window.closeExcludeModal; + delete window.modelDuplicatesManager; + }); + + it('wires page controls and exposes modal helpers during construction', () => { + const manager = new OtherPageManager(); + + expect(createPageControlsMock).toHaveBeenCalledWith('other'); + expect(duplicatesManagerMock).toHaveBeenCalledWith(manager, 'other'); + + expect(window.confirmDelete).toBe(confirmDeleteMock); + expect(window.closeDeleteModal).toBe(closeDeleteModalMock); + expect(window.confirmExclude).toBe(confirmExcludeMock); + expect(window.closeExcludeModal).toBe(closeExcludeModalMock); + expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance); + }); + + it('initializes shared page features and syncs active filters', async () => { + const manager = new OtherPageManager(); + + await manager.initialize(); + + expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1); + expect(initActiveFiltersSyncMock).toHaveBeenCalledWith('other'); + }); + + it('boots the other models page through the initializer', async () => { + const manager = await initializeOtherPage(); + + expect(initializeAppMock).toHaveBeenCalledTimes(1); + expect(manager).toBeInstanceOf(OtherPageManager); + expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance); + }); +}); diff --git a/tests/frontend/utils/pageFixtures.js b/tests/frontend/utils/pageFixtures.js index 465ea8b3..df8bcb24 100644 --- a/tests/frontend/utils/pageFixtures.js +++ b/tests/frontend/utils/pageFixtures.js @@ -36,6 +36,18 @@ export function renderEmbeddingsPage() { }); } +/** + * Renders the Other Models page template with expected dataset attributes. + * @returns {Element} + */ +export function renderOtherPage() { + return renderTemplate('other.html', { + dataset: { + page: 'other', + }, + }); +} + /** * Renders the Recipes page template with expected dataset attributes. * @returns {Element}