feat: Dynamic base model fetching from Civitai API (#854)

Implement automatic fetching of base models from Civitai API to keep
data up-to-date without manual updates.

Backend:
- Add CivitaiBaseModelService with 7-day TTL caching
- Add /api/lm/base-models endpoints for fetching and refreshing
- Merge hardcoded and remote models for backward compatibility
- Smart abbreviation generation for unknown models

Frontend:
- Add civitaiBaseModelApi client for API communication
- Dynamic base model loading on app initialization
- Update SettingsManager to use merged model lists
- Add support for 8 new models: Anima, CogVideoX, LTXV 2.3, Mochi,
  Pony V7, Wan Video 2.5 T2V/I2V

API Endpoints:
- GET /api/lm/base-models - Get merged models
- POST /api/lm/base-models/refresh - Force refresh
- GET /api/lm/base-models/categories - Get categories
- GET /api/lm/base-models/cache-status - Check cache status

Closes #854
This commit is contained in:
Will Miao
2026-03-29 00:18:15 +08:00
parent 89b1675ec7
commit 00f5c1e887
12 changed files with 1227 additions and 9 deletions

View File

@@ -0,0 +1,164 @@
/**
* API client for Civitai base model management
* Handles fetching and refreshing base models from Civitai API
*/
import { showToast } from '../utils/uiHelpers.js';
const BASE_MODEL_ENDPOINTS = {
getModels: '/api/lm/base-models',
refresh: '/api/lm/base-models/refresh',
categories: '/api/lm/base-models/categories',
cacheStatus: '/api/lm/base-models/cache-status',
};
/**
* Civitai Base Model API Client
*/
export class CivitaiBaseModelApi {
constructor() {
this.cache = null;
this.cacheTimestamp = null;
}
/**
* Get base models (with caching)
* @param {boolean} forceRefresh - Force refresh from API
* @returns {Promise<Object>} Response with models, source, and counts
*/
async getBaseModels(forceRefresh = false) {
try {
const url = new URL(BASE_MODEL_ENDPOINTS.getModels, window.location.origin);
if (forceRefresh) {
url.searchParams.append('refresh', 'true');
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch base models: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
this.cache = data.data;
this.cacheTimestamp = Date.now();
return data.data;
} else {
throw new Error(data.error || 'Failed to fetch base models');
}
} catch (error) {
console.error('Error fetching base models:', error);
showToast('Failed to fetch base models', { message: error.message }, 'error');
throw error;
}
}
/**
* Force refresh base models from Civitai API
* @returns {Promise<Object>} Refreshed data
*/
async refreshBaseModels() {
try {
const response = await fetch(BASE_MODEL_ENDPOINTS.refresh, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`Failed to refresh base models: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
this.cache = data.data;
this.cacheTimestamp = Date.now();
showToast('Base models refreshed successfully', {}, 'success');
return data.data;
} else {
throw new Error(data.error || 'Failed to refresh base models');
}
} catch (error) {
console.error('Error refreshing base models:', error);
showToast('Failed to refresh base models', { message: error.message }, 'error');
throw error;
}
}
/**
* Get base model categories
* @returns {Promise<Object>} Categories with model lists
*/
async getCategories() {
try {
const response = await fetch(BASE_MODEL_ENDPOINTS.categories);
if (!response.ok) {
throw new Error(`Failed to fetch categories: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
return data.data;
} else {
throw new Error(data.error || 'Failed to fetch categories');
}
} catch (error) {
console.error('Error fetching categories:', error);
throw error;
}
}
/**
* Get cache status
* @returns {Promise<Object>} Cache status information
*/
async getCacheStatus() {
try {
const response = await fetch(BASE_MODEL_ENDPOINTS.cacheStatus);
if (!response.ok) {
throw new Error(`Failed to fetch cache status: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
return data.data;
} else {
throw new Error(data.error || 'Failed to fetch cache status');
}
} catch (error) {
console.error('Error fetching cache status:', error);
throw error;
}
}
/**
* Get cached models (if available)
* @returns {Object|null} Cached data or null
*/
getCachedModels() {
return this.cache;
}
/**
* Check if cache is available
* @returns {boolean}
*/
hasCache() {
return this.cache !== null;
}
/**
* Get cache age in milliseconds
* @returns {number|null} Age in ms or null if no cache
*/
getCacheAge() {
if (!this.cacheTimestamp) return null;
return Date.now() - this.cacheTimestamp;
}
}
// Export singleton instance
export const civitaiBaseModelApi = new CivitaiBaseModelApi();

View File

@@ -17,6 +17,8 @@ import { onboardingManager } from './managers/OnboardingManager.js';
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
import { initializeEventManagement } from './utils/eventManagementInit.js';
import { civitaiBaseModelApi } from './api/civitaiBaseModelApi.js';
import { setDynamicBaseModels } from './utils/constants.js';
// Core application class
export class AppCore {
@@ -42,6 +44,10 @@ export class AppCore {
await settingsManager.waitForInitialization();
console.log('AppCore: Settings initialized');
// Initialize dynamic base models (async, non-blocking)
console.log('AppCore: Initializing dynamic base models...');
this.initializeDynamicBaseModels();
// Initialize managers
state.loadingManager = new LoadingManager();
modalManager.initialize();
@@ -116,6 +122,21 @@ export class AppCore {
window.globalContextMenuInstance = createGlobalContextMenu();
}
}
// Initialize dynamic base models from Civitai API
// This is non-blocking - runs in background
async initializeDynamicBaseModels() {
try {
const result = await civitaiBaseModelApi.getBaseModels();
if (result && result.models) {
setDynamicBaseModels(result.models, result.last_updated);
console.log(`AppCore: Loaded ${result.merged_count} base models (${result.hardcoded_count} hardcoded + ${result.remote_count} remote)`);
}
} catch (error) {
console.warn('AppCore: Failed to load dynamic base models:', error);
// Non-critical error - app continues with hardcoded models
}
}
}
// Create and export a singleton instance

View File

@@ -2,7 +2,14 @@ import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { state, createDefaultSettings } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
import {
DOWNLOAD_PATH_TEMPLATES,
MAPPABLE_BASE_MODELS,
PATH_TEMPLATE_PLACEHOLDERS,
DEFAULT_PATH_TEMPLATES,
DEFAULT_PRIORITY_TAG_CONFIG,
getMappableBaseModelsDynamic
} from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
@@ -184,7 +191,9 @@ export class SettingsManager {
}
getAvailableDownloadSkipBaseModels() {
return MAPPABLE_BASE_MODELS.filter(model => model !== 'Other');
// Use dynamic base models if available, fallback to hardcoded
const models = getMappableBaseModelsDynamic();
return models.filter(model => model !== 'Other');
}
normalizeDownloadSkipBaseModels(value) {
@@ -1517,7 +1526,7 @@ export class SettingsManager {
const row = document.createElement('div');
row.className = 'mapping-row';
const availableModels = MAPPABLE_BASE_MODELS.filter(model => {
const availableModels = getMappableBaseModelsDynamic().filter(model => {
const existingMappings = state.global.settings.base_model_path_mappings || {};
return !existingMappings.hasOwnProperty(model) || model === baseModel;
});
@@ -1619,7 +1628,7 @@ export class SettingsManager {
const currentValue = select.value;
// Get available models (not already mapped, except current)
const availableModels = MAPPABLE_BASE_MODELS.filter(model =>
const availableModels = getMappableBaseModelsDynamic().filter(model =>
!existingMappings.hasOwnProperty(model) || model === currentValue
);

View File

@@ -50,6 +50,9 @@ export const BASE_MODELS = {
SVD: "SVD",
LTXV: "LTXV",
LTXV2: "LTXV2",
LTXV_2_3: "LTXV 2.3",
COGVIDE_X: "CogVideoX",
MOCHI: "Mochi",
WAN_VIDEO: "Wan Video",
WAN_VIDEO_1_3B_T2V: "Wan Video 1.3B t2v",
WAN_VIDEO_14B_T2V: "Wan Video 14B t2v",
@@ -58,7 +61,12 @@ export const BASE_MODELS = {
WAN_VIDEO_2_2_TI2V_5B: "Wan Video 2.2 TI2V-5B",
WAN_VIDEO_2_2_T2V_A14B: "Wan Video 2.2 T2V-A14B",
WAN_VIDEO_2_2_I2V_A14B: "Wan Video 2.2 I2V-A14B",
WAN_VIDEO_2_5_T2V: "Wan Video 2.5 T2V",
WAN_VIDEO_2_5_I2V: "Wan Video 2.5 I2V",
HUNYUAN_VIDEO: "Hunyuan Video",
// Other models
ANIMA: "Anima",
PONY_V7: "Pony V7",
// Default
UNKNOWN: "Other"
};
@@ -151,6 +159,9 @@ export const BASE_MODEL_ABBREVIATIONS = {
[BASE_MODELS.SVD]: 'SVD',
[BASE_MODELS.LTXV]: 'LTXV',
[BASE_MODELS.LTXV2]: 'LTV2',
[BASE_MODELS.LTXV_2_3]: 'LTX',
[BASE_MODELS.COGVIDE_X]: 'CVX',
[BASE_MODELS.MOCHI]: 'MCHI',
[BASE_MODELS.WAN_VIDEO]: 'WAN',
[BASE_MODELS.WAN_VIDEO_1_3B_T2V]: 'WAN',
[BASE_MODELS.WAN_VIDEO_14B_T2V]: 'WAN',
@@ -159,8 +170,28 @@ export const BASE_MODEL_ABBREVIATIONS = {
[BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B]: 'WAN',
[BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B]: 'WAN',
[BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B]: 'WAN',
[BASE_MODELS.WAN_VIDEO_2_5_T2V]: 'WAN',
[BASE_MODELS.WAN_VIDEO_2_5_I2V]: 'WAN',
[BASE_MODELS.HUNYUAN_VIDEO]: 'HYV',
// Other diffusion models
[BASE_MODELS.AURAFLOW]: 'AF',
[BASE_MODELS.CHROMA]: 'CHR',
[BASE_MODELS.PIXART_A]: 'PXA',
[BASE_MODELS.PIXART_E]: 'PXE',
[BASE_MODELS.HUNYUAN_1]: 'HY',
[BASE_MODELS.LUMINA]: 'L',
[BASE_MODELS.KOLORS]: 'KLR',
[BASE_MODELS.NOOBAI]: 'NAI',
[BASE_MODELS.ILLUSTRIOUS]: 'IL',
[BASE_MODELS.PONY]: 'PONY',
[BASE_MODELS.PONY_V7]: 'PNY7',
[BASE_MODELS.HIDREAM]: 'HID',
[BASE_MODELS.QWEN]: 'QWEN',
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
[BASE_MODELS.ANIMA]: 'ANI',
// Default
[BASE_MODELS.UNKNOWN]: 'OTH'
};
@@ -349,18 +380,20 @@ export const BASE_MODEL_CATEGORIES = {
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.LTXV2, BASE_MODELS.HUNYUAN_VIDEO, BASE_MODELS.WAN_VIDEO,
BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.LTXV2, BASE_MODELS.LTXV_2_3,
BASE_MODELS.COGVIDE_X, BASE_MODELS.MOCHI, BASE_MODELS.HUNYUAN_VIDEO,
BASE_MODELS.WAN_VIDEO, BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
BASE_MODELS.WAN_VIDEO_14B_I2V_480P, BASE_MODELS.WAN_VIDEO_14B_I2V_720P,
BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B, BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B,
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B, BASE_MODELS.WAN_VIDEO_2_5_T2V,
BASE_MODELS.WAN_VIDEO_2_5_I2V
],
'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA, BASE_MODELS.FLUX_2_D, BASE_MODELS.FLUX_2_KLEIN_9B, BASE_MODELS.FLUX_2_KLEIN_9B_BASE, BASE_MODELS.FLUX_2_KLEIN_4B, BASE_MODELS.FLUX_2_KLEIN_4B_BASE],
'Other Models': [
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.PONY_V7, BASE_MODELS.HIDREAM,
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
BASE_MODELS.UNKNOWN
]
};
@@ -378,3 +411,94 @@ export const DEFAULT_PRIORITY_TAG_CONFIG = {
checkpoint: DEFAULT_PRIORITY_TAG_ENTRIES.join(', '),
embedding: DEFAULT_PRIORITY_TAG_ENTRIES.join(', ')
};
// ============================================================================
// Dynamic Base Model Support
// ============================================================================
/**
* Dynamic base model cache
* Stores models fetched from Civitai API
*/
let dynamicBaseModels = null;
let dynamicBaseModelsTimestamp = null;
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
* Set dynamic base models (called after fetching from API)
* @param {Array} models - Array of base model names
* @param {string} timestamp - ISO timestamp of fetch
*/
export function setDynamicBaseModels(models, timestamp) {
dynamicBaseModels = models;
dynamicBaseModelsTimestamp = timestamp;
}
/**
* Get dynamic base models
* @returns {Object|null} { models, timestamp } or null if not set
*/
export function getDynamicBaseModels() {
if (!dynamicBaseModels) return null;
// Check if cache is expired
if (dynamicBaseModelsTimestamp) {
const age = Date.now() - new Date(dynamicBaseModelsTimestamp).getTime();
if (age > CACHE_TTL_MS) {
dynamicBaseModels = null;
dynamicBaseModelsTimestamp = null;
return null;
}
}
return {
models: dynamicBaseModels,
timestamp: dynamicBaseModelsTimestamp
};
}
/**
* Get merged base models (hardcoded + dynamic)
* Returns unique sorted list of all available base models
* @returns {Array} Sorted array of base model names
*/
export function getMergedBaseModels() {
const hardcoded = Object.values(BASE_MODELS);
const dynamic = getDynamicBaseModels();
if (!dynamic || !dynamic.models) {
return hardcoded.sort();
}
// Merge and deduplicate
const merged = new Set([...hardcoded, ...dynamic.models]);
return Array.from(merged).sort();
}
/**
* Get mappable base models (for UI selection)
* Excludes 'Other' value
* @returns {Array} Sorted array of base model names (excluding 'Other')
*/
export function getMappableBaseModelsDynamic() {
const merged = getMergedBaseModels();
return merged.filter(model => model !== 'Other');
}
/**
* Clear dynamic base models cache
*/
export function clearDynamicBaseModels() {
dynamicBaseModels = null;
dynamicBaseModelsTimestamp = null;
}
/**
* Check if dynamic base models cache is valid
* @returns {boolean}
*/
export function isDynamicBaseModelsCacheValid() {
if (!dynamicBaseModels || !dynamicBaseModelsTimestamp) return false;
const age = Date.now() - new Date(dynamicBaseModelsTimestamp).getTime();
return age <= CACHE_TTL_MS;
}