feat(frontend): add Other Models page with subtype filter and badges

This commit is contained in:
Will Miao
2026-09-12 11:25:51 +08:00
parent 27da7b3ca3
commit fa7ce725c1
45 changed files with 988 additions and 30 deletions
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Checkpoint-Name kopieren", "copyCheckpointName": "Checkpoint-Name kopieren",
"copyEmbeddingName": "Embedding-Name kopieren", "copyEmbeddingName": "Embedding-Name kopieren",
"embeddingNameCopied": "Embedding-Syntax kopiert", "embeddingNameCopied": "Embedding-Syntax kopiert",
"modelNameCopied": "[TODO: Translate] Model name copied",
"sendCheckpointToWorkflow": "An ComfyUI senden", "sendCheckpointToWorkflow": "An ComfyUI senden",
"sendEmbeddingToWorkflow": "An ComfyUI senden" "sendEmbeddingToWorkflow": "An ComfyUI senden"
}, },
@@ -233,6 +234,7 @@
"recipes": "Rezepte", "recipes": "Rezepte",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"other": "[TODO: Translate] Other",
"statistics": "Statistiken" "statistics": "Statistiken"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "Embedding-Modelle" "title": "Embedding-Modelle"
}, },
"other": {
"title": "[TODO: Translate] Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "Stammverzeichnis", "modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen", "collapseAll": "Alle Ordner einklappen",
@@ -1878,6 +1883,10 @@
"title": "Embedding Manager wird initialisiert", "title": "Embedding Manager wird initialisiert",
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..." "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": { "recipes": {
"title": "Rezept Manager wird initialisiert", "title": "Rezept Manager wird initialisiert",
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..." "message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Copy checkpoint name", "copyCheckpointName": "Copy checkpoint name",
"copyEmbeddingName": "Copy embedding name", "copyEmbeddingName": "Copy embedding name",
"embeddingNameCopied": "Embedding syntax copied", "embeddingNameCopied": "Embedding syntax copied",
"modelNameCopied": "Model name copied",
"sendCheckpointToWorkflow": "Send to ComfyUI", "sendCheckpointToWorkflow": "Send to ComfyUI",
"sendEmbeddingToWorkflow": "Send to ComfyUI" "sendEmbeddingToWorkflow": "Send to ComfyUI"
}, },
@@ -233,6 +234,7 @@
"recipes": "Recipes", "recipes": "Recipes",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"other": "Other",
"statistics": "Stats" "statistics": "Stats"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "Embedding Models" "title": "Embedding Models"
}, },
"other": {
"title": "Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "Root", "modelRoot": "Root",
"collapseAll": "Collapse All Folders", "collapseAll": "Collapse All Folders",
@@ -1878,6 +1883,10 @@
"title": "Initializing Embedding Manager", "title": "Initializing Embedding Manager",
"message": "Scanning and building embedding cache. This may take a few minutes..." "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": { "recipes": {
"title": "Initializing Recipe Manager", "title": "Initializing Recipe Manager",
"message": "Loading and processing recipes. This may take a few minutes..." "message": "Loading and processing recipes. This may take a few minutes..."
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Copiar nombre del checkpoint", "copyCheckpointName": "Copiar nombre del checkpoint",
"copyEmbeddingName": "Copiar nombre del embedding", "copyEmbeddingName": "Copiar nombre del embedding",
"embeddingNameCopied": "Sintaxis de embedding copiada", "embeddingNameCopied": "Sintaxis de embedding copiada",
"modelNameCopied": "[TODO: Translate] Model name copied",
"sendCheckpointToWorkflow": "Enviar a ComfyUI", "sendCheckpointToWorkflow": "Enviar a ComfyUI",
"sendEmbeddingToWorkflow": "Enviar a ComfyUI" "sendEmbeddingToWorkflow": "Enviar a ComfyUI"
}, },
@@ -233,6 +234,7 @@
"recipes": "Recetas", "recipes": "Recetas",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"other": "[TODO: Translate] Other",
"statistics": "Estadísticas" "statistics": "Estadísticas"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "Modelos embedding" "title": "Modelos embedding"
}, },
"other": {
"title": "[TODO: Translate] Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "Raíz", "modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas", "collapseAll": "Colapsar todas las carpetas",
@@ -1878,6 +1883,10 @@
"title": "Inicializando gestor de embedding", "title": "Inicializando gestor de embedding",
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..." "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": { "recipes": {
"title": "Inicializando gestor de recetas", "title": "Inicializando gestor de recetas",
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..." "message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Copier le nom du checkpoint", "copyCheckpointName": "Copier le nom du checkpoint",
"copyEmbeddingName": "Copier le nom de l'embedding", "copyEmbeddingName": "Copier le nom de l'embedding",
"embeddingNameCopied": "Syntaxe dembedding copiée", "embeddingNameCopied": "Syntaxe dembedding copiée",
"modelNameCopied": "[TODO: Translate] Model name copied",
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI", "sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI" "sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
}, },
@@ -233,6 +234,7 @@
"recipes": "Recipes", "recipes": "Recipes",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"other": "[TODO: Translate] Other",
"statistics": "Statistiques" "statistics": "Statistiques"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "Modèles Embedding" "title": "Modèles Embedding"
}, },
"other": {
"title": "[TODO: Translate] Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "Racine", "modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers", "collapseAll": "Réduire tous les dossiers",
@@ -1878,6 +1883,10 @@
"title": "Initialisation du gestionnaire Embedding", "title": "Initialisation du gestionnaire Embedding",
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..." "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": { "recipes": {
"title": "Initialisation du gestionnaire de recipes", "title": "Initialisation du gestionnaire de recipes",
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..." "message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "העתק שם Checkpoint", "copyCheckpointName": "העתק שם Checkpoint",
"copyEmbeddingName": "העתק שם Embedding", "copyEmbeddingName": "העתק שם Embedding",
"embeddingNameCopied": "תחביר Embedding הועתק", "embeddingNameCopied": "תחביר Embedding הועתק",
"modelNameCopied": "[TODO: Translate] Model name copied",
"sendCheckpointToWorkflow": "שלח ל-ComfyUI", "sendCheckpointToWorkflow": "שלח ל-ComfyUI",
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI" "sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
}, },
@@ -233,6 +234,7 @@
"recipes": "מתכונים", "recipes": "מתכונים",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"other": "[TODO: Translate] Other",
"statistics": "סטטיסטיקה" "statistics": "סטטיסטיקה"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "מודלי Embedding" "title": "מודלי Embedding"
}, },
"other": {
"title": "[TODO: Translate] Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות", "collapseAll": "כווץ את כל התיקיות",
@@ -1878,6 +1883,10 @@
"title": "מאתחל מנהל Embedding", "title": "מאתחל מנהל Embedding",
"message": "סורק ובונה מטמון 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": { "recipes": {
"title": "מאתחל מנהל מתכונים", "title": "מאתחל מנהל מתכונים",
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..." "message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Checkpoint名をコピー", "copyCheckpointName": "Checkpoint名をコピー",
"copyEmbeddingName": "embedding名をコピー", "copyEmbeddingName": "embedding名をコピー",
"embeddingNameCopied": "Embedding構文をコピーしました", "embeddingNameCopied": "Embedding構文をコピーしました",
"modelNameCopied": "[TODO: Translate] Model name copied",
"sendCheckpointToWorkflow": "ComfyUIに送信", "sendCheckpointToWorkflow": "ComfyUIに送信",
"sendEmbeddingToWorkflow": "ComfyUIに送信" "sendEmbeddingToWorkflow": "ComfyUIに送信"
}, },
@@ -233,6 +234,7 @@
"recipes": "レシピ", "recipes": "レシピ",
"checkpoints": "Checkpoint", "checkpoints": "Checkpoint",
"embeddings": "Embedding", "embeddings": "Embedding",
"other": "[TODO: Translate] Other",
"statistics": "統計" "statistics": "統計"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "Embeddingモデル" "title": "Embeddingモデル"
}, },
"other": {
"title": "[TODO: Translate] Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ", "collapseAll": "すべてのフォルダを折りたたむ",
@@ -1878,6 +1883,10 @@
"title": "Embedding Managerを初期化中", "title": "Embedding Managerを初期化中",
"message": "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": { "recipes": {
"title": "レシピマネージャーを初期化中", "title": "レシピマネージャーを初期化中",
"message": "レシピを読み込んで処理中。数分かかる場合があります..." "message": "レシピを読み込んで処理中。数分かかる場合があります..."
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Checkpoint 이름 복사", "copyCheckpointName": "Checkpoint 이름 복사",
"copyEmbeddingName": "Embedding 이름 복사", "copyEmbeddingName": "Embedding 이름 복사",
"embeddingNameCopied": "Embedding 구문 복사됨", "embeddingNameCopied": "Embedding 구문 복사됨",
"modelNameCopied": "[TODO: Translate] Model name copied",
"sendCheckpointToWorkflow": "ComfyUI로 전송", "sendCheckpointToWorkflow": "ComfyUI로 전송",
"sendEmbeddingToWorkflow": "ComfyUI로 전송" "sendEmbeddingToWorkflow": "ComfyUI로 전송"
}, },
@@ -233,6 +234,7 @@
"recipes": "레시피", "recipes": "레시피",
"checkpoints": "Checkpoint", "checkpoints": "Checkpoint",
"embeddings": "Embedding", "embeddings": "Embedding",
"other": "[TODO: Translate] Other",
"statistics": "통계" "statistics": "통계"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "Embedding 모델" "title": "Embedding 모델"
}, },
"other": {
"title": "[TODO: Translate] Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
"collapseAll": "모든 폴더 접기", "collapseAll": "모든 폴더 접기",
@@ -1878,6 +1883,10 @@
"title": "Embedding Manager 초기화 중", "title": "Embedding Manager 초기화 중",
"message": "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": { "recipes": {
"title": "레시피 매니저 초기화 중", "title": "레시피 매니저 초기화 중",
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..." "message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Копировать имя checkpoint", "copyCheckpointName": "Копировать имя checkpoint",
"copyEmbeddingName": "Копировать имя embedding", "copyEmbeddingName": "Копировать имя embedding",
"embeddingNameCopied": "Синтаксис embedding скопирован", "embeddingNameCopied": "Синтаксис embedding скопирован",
"modelNameCopied": "[TODO: Translate] Model name copied",
"sendCheckpointToWorkflow": "Отправить в ComfyUI", "sendCheckpointToWorkflow": "Отправить в ComfyUI",
"sendEmbeddingToWorkflow": "Отправить в ComfyUI" "sendEmbeddingToWorkflow": "Отправить в ComfyUI"
}, },
@@ -233,6 +234,7 @@
"recipes": "Рецепты", "recipes": "Рецепты",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"other": "[TODO: Translate] Other",
"statistics": "Статистика" "statistics": "Статистика"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "Модели Embedding" "title": "Модели Embedding"
}, },
"other": {
"title": "[TODO: Translate] Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
"collapseAll": "Свернуть все папки", "collapseAll": "Свернуть все папки",
@@ -1878,6 +1883,10 @@
"title": "Инициализация Embedding Manager", "title": "Инициализация Embedding Manager",
"message": "Сканирование и построение кэша 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": { "recipes": {
"title": "Инициализация менеджера рецептов", "title": "Инициализация менеджера рецептов",
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..." "message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "复制 Checkpoint 名称", "copyCheckpointName": "复制 Checkpoint 名称",
"copyEmbeddingName": "复制 Embedding 名称", "copyEmbeddingName": "复制 Embedding 名称",
"embeddingNameCopied": "已复制 Embedding 语法", "embeddingNameCopied": "已复制 Embedding 语法",
"modelNameCopied": "[TODO: Translate] Model name copied",
"sendCheckpointToWorkflow": "发送到 ComfyUI", "sendCheckpointToWorkflow": "发送到 ComfyUI",
"sendEmbeddingToWorkflow": "发送到 ComfyUI" "sendEmbeddingToWorkflow": "发送到 ComfyUI"
}, },
@@ -233,6 +234,7 @@
"recipes": "配方", "recipes": "配方",
"checkpoints": "Checkpoint", "checkpoints": "Checkpoint",
"embeddings": "Embedding", "embeddings": "Embedding",
"other": "[TODO: Translate] Other",
"statistics": "统计" "statistics": "统计"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "Embedding 模型" "title": "Embedding 模型"
}, },
"other": {
"title": "[TODO: Translate] Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
"collapseAll": "折叠所有文件夹", "collapseAll": "折叠所有文件夹",
@@ -1878,6 +1883,10 @@
"title": "初始化 Embedding 管理器", "title": "初始化 Embedding 管理器",
"message": "正在扫描并构建 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": { "recipes": {
"title": "初始化配方管理器", "title": "初始化配方管理器",
"message": "正在加载和处理配方。这可能需要几分钟..." "message": "正在加载和处理配方。这可能需要几分钟..."
+9
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "複製 Checkpoint 名稱", "copyCheckpointName": "複製 Checkpoint 名稱",
"copyEmbeddingName": "複製嵌入名稱", "copyEmbeddingName": "複製嵌入名稱",
"embeddingNameCopied": "已複製 Embedding 語法", "embeddingNameCopied": "已複製 Embedding 語法",
"modelNameCopied": "[TODO: Translate] Model name copied",
"sendCheckpointToWorkflow": "傳送到 ComfyUI", "sendCheckpointToWorkflow": "傳送到 ComfyUI",
"sendEmbeddingToWorkflow": "傳送到 ComfyUI" "sendEmbeddingToWorkflow": "傳送到 ComfyUI"
}, },
@@ -233,6 +234,7 @@
"recipes": "配方", "recipes": "配方",
"checkpoints": "Checkpoint", "checkpoints": "Checkpoint",
"embeddings": "Embedding", "embeddings": "Embedding",
"other": "[TODO: Translate] Other",
"statistics": "統計" "statistics": "統計"
}, },
"search": { "search": {
@@ -1201,6 +1203,9 @@
"embeddings": { "embeddings": {
"title": "Embedding 模型" "title": "Embedding 模型"
}, },
"other": {
"title": "[TODO: Translate] Other Models"
},
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾", "collapseAll": "全部摺疊資料夾",
@@ -1878,6 +1883,10 @@
"title": "初始化 Embedding 管理器", "title": "初始化 Embedding 管理器",
"message": "正在掃描並建立 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": { "recipes": {
"title": "初始化配方管理器", "title": "初始化配方管理器",
"message": "正在載入並處理配方,可能需要幾分鐘..." "message": "正在載入並處理配方,可能需要幾分鐘..."
+14 -1
View File
@@ -9,7 +9,8 @@ import { state } from '../state/index.js';
export const MODEL_TYPES = { export const MODEL_TYPES = {
LORA: 'loras', LORA: 'loras',
CHECKPOINT: 'checkpoints', CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings' // Future model type EMBEDDING: 'embeddings',
OTHER: 'other'
}; };
// Base API configuration for each model type // Base API configuration for each model type
@@ -40,6 +41,15 @@ export const MODEL_CONFIG = {
supportsBulkOperations: true, supportsBulkOperations: true,
supportsMove: true, supportsMove: true,
templateName: 'embeddings.html' 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]: { [MODEL_TYPES.EMBEDDING]: {
metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`, metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
},
[MODEL_TYPES.OTHER]: {
metadata: `/api/lm/${MODEL_TYPES.OTHER}/metadata`,
} }
}; };
+3
View File
@@ -1,6 +1,7 @@
import { LoraApiClient } from './loraApi.js'; import { LoraApiClient } from './loraApi.js';
import { CheckpointApiClient } from './checkpointApi.js'; import { CheckpointApiClient } from './checkpointApi.js';
import { EmbeddingApiClient } from './embeddingApi.js'; import { EmbeddingApiClient } from './embeddingApi.js';
import { OtherApiClient } from './otherApi.js';
import { MODEL_TYPES, isValidModelType } from './apiConfig.js'; import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
@@ -12,6 +13,8 @@ export function createModelApiClient(modelType) {
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT); return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
case MODEL_TYPES.EMBEDDING: case MODEL_TYPES.EMBEDDING:
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING); return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
case MODEL_TYPES.OTHER:
return new OtherApiClient(MODEL_TYPES.OTHER);
default: default:
throw new Error(`Unsupported model type: ${modelType}`); throw new Error(`Unsupported model type: ${modelType}`);
} }
+7
View File
@@ -0,0 +1,7 @@
import { BaseModelApiClient } from './baseModelApi.js';
/**
* Other-models-specific API client (VAE, upscalers, text encoders, etc.)
*/
export class OtherApiClient extends BaseModelApiClient {
}
@@ -139,8 +139,8 @@ export class BulkContextMenu extends BaseContextMenu {
const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]'); const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
if (downloadExampleImagesSubmenu) { if (downloadExampleImagesSubmenu) {
// Show on model pages (loras, checkpoints, embeddings), hide on recipes // Show on model pages (loras, checkpoints, embeddings, other), hide on recipes
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none'; downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings', 'other'].includes(currentModelType) ? 'flex' : 'none';
} }
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]'); const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
@@ -112,7 +112,8 @@ export const ModelContextMenuMixin = {
const prefixMap = { const prefixMap = {
lora: 'loras', lora: 'loras',
checkpoint: 'checkpoints', checkpoint: 'checkpoints',
embedding: 'embeddings' embedding: 'embeddings',
other: 'other'
}; };
return prefixMap[this.modelType] || 'loras'; return prefixMap[this.modelType] || 'loras';
}, },
@@ -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);
@@ -2,6 +2,7 @@ export { LoraContextMenu } from './LoraContextMenu.js';
export { RecipeContextMenu } from './RecipeContextMenu.js'; export { RecipeContextMenu } from './RecipeContextMenu.js';
export { CheckpointContextMenu } from './CheckpointContextMenu.js'; export { CheckpointContextMenu } from './CheckpointContextMenu.js';
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
export { OtherContextMenu } from './OtherContextMenu.js';
export { GlobalContextMenu } from './GlobalContextMenu.js'; export { GlobalContextMenu } from './GlobalContextMenu.js';
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
@@ -9,6 +10,7 @@ import { LoraContextMenu } from './LoraContextMenu.js';
import { RecipeContextMenu } from './RecipeContextMenu.js'; import { RecipeContextMenu } from './RecipeContextMenu.js';
import { CheckpointContextMenu } from './CheckpointContextMenu.js'; import { CheckpointContextMenu } from './CheckpointContextMenu.js';
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
import { OtherContextMenu } from './OtherContextMenu.js';
import { GlobalContextMenu } from './GlobalContextMenu.js'; import { GlobalContextMenu } from './GlobalContextMenu.js';
// Factory method to create page-specific context menu instances // Factory method to create page-specific context menu instances
@@ -22,6 +24,8 @@ export function createPageContextMenu(pageType) {
return new CheckpointContextMenu(); return new CheckpointContextMenu();
case 'embeddings': case 'embeddings':
return new EmbeddingContextMenu(); return new EmbeddingContextMenu();
case 'other':
return new OtherContextMenu();
default: default:
return null; return null;
} }
+1
View File
@@ -32,6 +32,7 @@ export class HeaderManager {
if (path.includes('/loras/recipes')) return 'recipes'; if (path.includes('/loras/recipes')) return 'recipes';
if (path.includes('/checkpoints')) return 'checkpoints'; if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/embeddings')) return 'embeddings'; if (path.includes('/embeddings')) return 'embeddings';
if (path.includes('/other')) return 'other';
if (path.includes('/statistics')) return 'statistics'; if (path.includes('/statistics')) return 'statistics';
if (path.includes('/loras')) return 'loras'; if (path.includes('/loras')) return 'loras';
return 'unknown'; return 'unknown';
+2 -1
View File
@@ -1126,6 +1126,7 @@ export class SidebarManager {
recipes: 'Recipes', recipes: 'Recipes',
checkpoints: 'Checkpoints', checkpoints: 'Checkpoints',
embeddings: 'Embeddings', embeddings: 'Embeddings',
other: 'Other Models',
}; };
return names[this.pageType] || this.pageType; return names[this.pageType] || this.pageType;
} }
@@ -1804,7 +1805,7 @@ export class SidebarManager {
_migrateOldSettings() { _migrateOldSettings() {
if (getStorageItem('_sidebar_migration_done')) return; 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 // 1. Migrate global hide setting to per-page
if (state?.global?.settings?.show_folder_sidebar === false) { if (state?.global?.settings?.show_folder_sidebar === false) {
@@ -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);
}
}
+5 -2
View File
@@ -3,13 +3,14 @@ import { PageControls } from './PageControls.js';
import { LorasControls } from './LorasControls.js'; import { LorasControls } from './LorasControls.js';
import { CheckpointsControls } from './CheckpointsControls.js'; import { CheckpointsControls } from './CheckpointsControls.js';
import { EmbeddingsControls } from './EmbeddingsControls.js'; import { EmbeddingsControls } from './EmbeddingsControls.js';
import { OtherControls } from './OtherControls.js';
// Export the classes // 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 * 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 * @returns {PageControls} - The appropriate controls instance
*/ */
export function createPageControls(pageType) { export function createPageControls(pageType) {
@@ -19,6 +20,8 @@ export function createPageControls(pageType) {
return new CheckpointsControls(); return new CheckpointsControls();
} else if (pageType === 'embeddings') { } else if (pageType === 'embeddings') {
return new EmbeddingsControls(); return new EmbeddingsControls();
} else if (pageType === 'other') {
return new OtherControls();
} else { } else {
console.error(`Unknown page type: ${pageType}`); console.error(`Unknown page type: ${pageType}`);
return null; return null;
+3
View File
@@ -58,6 +58,8 @@ class InitializationManager {
this.pageType = 'recipes'; this.pageType = 'recipes';
} else if (path.includes('/checkpoints')) { } else if (path.includes('/checkpoints')) {
this.pageType = 'checkpoints'; this.pageType = 'checkpoints';
} else if (path.includes('/other')) {
this.pageType = 'other';
} else if (path.includes('/loras')) { } else if (path.includes('/loras')) {
this.pageType = 'loras'; this.pageType = 'loras';
} else if (path.includes('/embeddings')) { } else if (path.includes('/embeddings')) {
@@ -221,6 +223,7 @@ class InitializationManager {
'lora': 'loras', 'lora': 'loras',
'checkpoint': 'checkpoints', 'checkpoint': 'checkpoints',
'embedding': 'embeddings', 'embedding': 'embeddings',
'other': 'other',
'recipe': 'recipes' 'recipe': 'recipes'
}; };
+5
View File
@@ -250,6 +250,11 @@ function handleCopyAction(card, modelType) {
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`; const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied'); const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
copyToClipboard(embeddingCode, message); 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);
} }
} }
+1 -1
View File
@@ -116,7 +116,7 @@ export class AppCore {
initializePageFeatures() { initializePageFeatures() {
const pageType = this.getPageType(); const pageType = this.getPageType();
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) { if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other'].includes(pageType)) {
this.initializeContextMenus(pageType); this.initializeContextMenus(pageType);
initializeInfiniteScroll(pageType); initializeInfiniteScroll(pageType);
} }
+4 -2
View File
@@ -424,12 +424,13 @@ class BannerService {
/** /**
* Get the current page type from the URL * 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() { getCurrentPageType() {
const path = window.location.pathname; const path = window.location.pathname;
if (path.includes('/checkpoints')) return 'checkpoints'; if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/embeddings')) return 'embeddings'; if (path.includes('/embeddings')) return 'embeddings';
if (path.includes('/other')) return 'other';
if (path.includes('/recipes')) return 'recipes'; if (path.includes('/recipes')) return 'recipes';
return 'loras'; return 'loras';
} }
@@ -443,7 +444,8 @@ class BannerService {
const endpoints = { const endpoints = {
'loras': '/api/lm/loras/reload?rebuild=true', 'loras': '/api/lm/loras/reload?rebuild=true',
'checkpoints': '/api/lm/checkpoints/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']; return endpoints[pageType] || endpoints['loras'];
} }
+14
View File
@@ -93,6 +93,20 @@ export class BulkManager {
setFavorite: true, setFavorite: true,
unfavorite: 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: { recipes: {
addTags: true, addTags: true,
sendToWorkflow: false, sendToWorkflow: false,
+2 -2
View File
@@ -805,7 +805,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type // Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true); 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 // For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false); await getModelApiClient().loadMoreWithVirtualScroll(true, false);
} }
@@ -904,7 +904,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page // Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true); 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); await getModelApiClient().loadMoreWithVirtualScroll(true, true);
} }
+4 -6
View File
@@ -60,7 +60,6 @@ class MoveManager {
this.bulkFilePaths = null; this.bulkFilePaths = null;
const apiClient = this._getApiClient(modelType); const apiClient = this._getApiClient(modelType);
const currentPageType = state.currentPageType;
const modelConfig = apiClient.apiConfig.config; const modelConfig = apiClient.apiConfig.config;
// Handle bulk mode // Handle bulk mode
@@ -113,7 +112,7 @@ class MoveManager {
).join(''); ).join('');
// Set default root if available // 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]; const defaultRoot = state.global.settings[settingsKey];
if (defaultRoot && rootsData.roots.includes(defaultRoot)) { if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
modelRootSelect.value = defaultRoot; modelRootSelect.value = defaultRoot;
@@ -228,13 +227,12 @@ class MoveManager {
if (modelRoot) { if (modelRoot) {
if (this.useDefaultPath) { if (this.useDefaultPath) {
// Show actual template path // Show actual template path
try { const singularType = config.singularName || apiClient.modelType.replace(/s$/, '');
const singularType = apiClient.modelType.replace(/s$/, '');
const templates = state.global.settings.download_path_templates; const templates = state.global.settings.download_path_templates;
const template = templates[singularType]; const template = templates[singularType];
if (template) {
fullPath += `/${template}`; fullPath += `/${template}`;
} catch (error) { } else {
console.error('Failed to fetch template:', error);
fullPath += '/' + translate('modals.download.autoOrganizedPath'); fullPath += '/' + translate('modals.download.autoOrganizedPath');
} }
} else { } else {
+2 -2
View File
@@ -298,7 +298,7 @@ export class SearchManager {
pageState.searchOptions.loraName = options.loraName || false; pageState.searchOptions.loraName = options.loraName || false;
pageState.searchOptions.loraModel = options.loraModel || false; pageState.searchOptions.loraModel = options.loraModel || false;
pageState.searchOptions.prompt = options.prompt || 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 // Update only the relevant fields in searchOptions instead of replacing the whole object
pageState.searchOptions.filename = options.filename || false; pageState.searchOptions.filename = options.filename || false;
pageState.searchOptions.modelname = options.modelname || 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 // Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
window.recipeManager.loadRecipes(true); 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 // For models page, reset the page and reload
getModelApiClient().loadMoreWithVirtualScroll(true, false); getModelApiClient().loadMoreWithVirtualScroll(true, false);
} }
+3
View File
@@ -3360,6 +3360,9 @@ export class SettingsManager {
} else if (this.currentPage === 'embeddings') { } else if (this.currentPage === 'embeddings') {
// Reload the embeddings without updating folders // Reload the embeddings without updating folders
await resetAndReload(false); await resetAndReload(false);
} else if (this.currentPage === 'other') {
// Reload the other models without updating folders
await resetAndReload(false);
} }
} }
+57
View File
@@ -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 };
+39
View File
@@ -79,6 +79,7 @@ export function createDefaultSettings() {
const loraPreviewVersions = getMapFromStorage('loras_preview_versions'); const loraPreviewVersions = getMapFromStorage('loras_preview_versions');
const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions'); const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions');
const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions'); const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions');
const otherPreviewVersions = getMapFromStorage('other_preview_versions');
export const state = { export const state = {
// Global state // Global state
@@ -234,6 +235,44 @@ export const state = {
search: '', search: '',
}, },
activeViewSnapshot: null, 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,
} }
}, },
+1 -1
View File
@@ -53,7 +53,7 @@ export function syncActiveFilters(pageType) {
* Register the storage listener and push the current (restored) state once. * Register the storage listener and push the current (restored) state once.
* The initial push covers server restarts, where the backend store is empty * The initial push covers server restarts, where the backend store is empty
* until the manager page re-publishes its localStorage-restored filters. * 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) { export function initActiveFiltersSync(pageType) {
setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType)); setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType));
+11
View File
@@ -106,6 +106,12 @@ export const MODEL_SUBTYPE_DISPLAY_NAMES = {
diffusion_model: "Diffusion Model", diffusion_model: "Diffusion Model",
// Embedding sub-types // Embedding sub-types
embedding: "Embedding", embedding: "Embedding",
// Other model sub-types
vae: "VAE",
upscaler: "Upscaler",
text_encoder: "Text Encoder",
clip_vision: "CLIP Vision",
controlnet: "ControlNet",
}; };
// Backward compatibility alias // Backward compatibility alias
@@ -119,6 +125,11 @@ export const MODEL_SUBTYPE_ABBREVIATIONS = {
checkpoint: "CKPT", checkpoint: "CKPT",
diffusion_model: "DM", diffusion_model: "DM",
embedding: "EMB", embedding: "EMB",
vae: "VAE",
upscaler: "UPS",
text_encoder: "TE",
clip_vision: "CV",
controlnet: "CN",
}; };
export function getSubTypeAbbreviation(subType) { export function getSubTypeAbbreviation(subType) {
+1 -1
View File
@@ -66,7 +66,7 @@ async function getCardCreator(pageType) {
// Function to get the appropriate data fetcher based on page type // Function to get the appropriate data fetcher based on page type
async function getDataFetcher(pageType) { 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); return (page = 1, pageSize = 100) => getModelApiClient().fetchModelsPage(page, pageSize);
} else if (pageType === 'recipes') { } else if (pageType === 'recipes') {
// Import the recipeApi module and use the fetchRecipesPage function // Import the recipeApi module and use the fetchRecipesPage function
+1 -1
View File
@@ -8,7 +8,7 @@ const STORAGE_PREFIX = 'lora_manager_';
// Matches keys that carry the manager page's active filter state // Matches keys that carry the manager page's active filter state
// (e.g. 'loras_activeFolder', 'checkpoints_filters'). // (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; let activeFiltersListener = null;
+3 -1
View File
@@ -36,7 +36,7 @@
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option> <option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
</optgroup> </optgroup>
{% endif %} {% endif %}
{% if page_id != 'embeddings' and page_id != 'recipes' %} {% if page_id != 'embeddings' and page_id != 'recipes' and page_id != 'other' %}
<optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}"> <optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}">
<option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option> <option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option>
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option> <option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
@@ -80,12 +80,14 @@
<div class="control-group"> <div class="control-group">
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span><span>{{ t('loras.controls.fetch.action') }}</span> <kbd class="shortcut-key">F</kbd></span></button> <button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span><span>{{ t('loras.controls.fetch.action') }}</span> <kbd class="shortcut-key">F</kbd></span></button>
</div> </div>
{% if page_id != 'other' %}
<div class="control-group"> <div class="control-group">
<button data-action="download" title="{{ t('loras.controls.download.title') }}"> <button data-action="download" title="{{ t('loras.controls.download.title') }}">
<i class="fas fa-cloud-download-alt"></i> <span><span>{{ t('loras.controls.download.action') }}</span> <kbd class="shortcut-key">D</kbd></span> <i class="fas fa-cloud-download-alt"></i> <span><span>{{ t('loras.controls.download.action') }}</span> <kbd class="shortcut-key">D</kbd></span>
</button> </button>
</div> </div>
{% endif %} {% endif %}
{% endif %}
{% if page_id == 'recipes' %} {% if page_id == 'recipes' %}
<div title="{{ t('recipes.controls.import.title') }}" class="control-group"> <div title="{{ t('recipes.controls.import.title') }}" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button> <button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
+8 -2
View File
@@ -15,6 +15,8 @@
{% set current_page = 'checkpoints' %} {% set current_page = 'checkpoints' %}
{% elif current_path.startswith('/embeddings') %} {% elif current_path.startswith('/embeddings') %}
{% set current_page = 'embeddings' %} {% set current_page = 'embeddings' %}
{% elif current_path.startswith('/other') %}
{% set current_page = 'other' %}
{% elif current_path.startswith('/statistics') %} {% elif current_path.startswith('/statistics') %}
{% set current_page = 'statistics' %} {% set current_page = 'statistics' %}
{% else %} {% else %}
@@ -36,6 +38,10 @@
id="embeddingsNavItem"> id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span> <i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a> </a>
<a href="/other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}"
id="otherNavItem">
<i class="fas fa-cubes"></i> <span>{{ t('header.navigation.other') }}</span>
</a>
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}" <a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem"> id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span> <i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
@@ -208,7 +214,7 @@
<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> <div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
{% elif request.path == '/embeddings' %} {% 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>
@@ -295,7 +301,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if current_page == 'loras' or current_page == 'checkpoints' %} {% if current_page == 'loras' or current_page == 'checkpoints' or current_page == 'other' %}
<div class="filter-section"> <div class="filter-section">
<h4>{{ t('header.filter.modelTypes') }}</h4> <h4>{{ t('header.filter.modelTypes') }}</h4>
<div class="filter-tags" id="modelTypeTags"> <div class="filter-tags" id="modelTypeTags">
+74
View File
@@ -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 %}
<div id="otherContextMenu" class="context-menu" style="display: none;">
<!-- Metadata -->
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
<i class="fas fa-link"></i>
<span>{{ t('loras.contextMenu.linkModel') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="relink-civitai">
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
</div>
<div class="context-menu-item" data-action="link-hf">
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
</div>
</div>
</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Workflow -->
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }} <i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingExamples') }}</div>
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
</div>
</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Attributes -->
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Organization -->
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.moveToFolder') }}</div>
<div class="context-menu-separator"></div>
<!-- Destructive -->
<div class="context-menu-item" data-action="exclude"><i class="fas fa-eye-slash"></i> {{ t('loras.contextMenu.excludeModel') }}</div>
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{ t('loras.contextMenu.deleteModel') }}</div>
</div>
{% endblock %}
{% block content %}
<div class="sticky-topbar">
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
</div>
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}
<!-- Other model cards container -->
<div class="card-grid" id="modelGrid">
<!-- Cards will be dynamically inserted here -->
</div>
{% endblock %}
{% block overlay %}
<div class="bulk-mode-overlay"></div>
{% endblock %}
{% block main_script %}
<script type="module" src="/loras_static/js/other.js?v={{ version }}"></script>
{% endblock %}
+85
View File
@@ -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');
});
});
@@ -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');
});
});
@@ -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 = '<div id="otherContextMenu" class="context-menu" style="display: none;"></div>';
});
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');
});
});
+1 -1
View File
@@ -120,7 +120,7 @@ import { initializeEventManagement } from '../../../static/js/utils/eventManagem
import { initializeInfiniteScroll } from '../../../static/js/utils/infiniteScroll.js'; import { initializeInfiniteScroll } from '../../../static/js/utils/infiniteScroll.js';
import { createPageContextMenu, createGlobalContextMenu } from '../../../static/js/components/ContextMenu/index.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', () => { describe('AppCore page orchestration', () => {
beforeEach(() => { beforeEach(() => {
+105
View File
@@ -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);
});
});
+12
View File
@@ -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. * Renders the Recipes page template with expected dataset attributes.
* @returns {Element} * @returns {Element}