Refactor API structure to unify model operations

- Introduced MODEL_TYPES and MODEL_CONFIG for centralized model type management.
- Created a unified API client for checkpoints and loras to streamline operations.
- Updated all API calls in checkpointApi.js and loraApi.js to use the new client.
- Simplified context menus and model card operations to leverage the unified API client.
- Enhanced state management to accommodate new model types and their configurations.
- Added virtual scrolling functions for recipes and improved loading states.
- Refactored modal utilities to handle model exclusion and deletion generically.
- Improved error handling and user feedback across various operations.
This commit is contained in:
Will Miao
2025-07-25 10:04:18 +08:00
parent 692796db46
commit d83fad6abc
15 changed files with 927 additions and 928 deletions

169
static/js/api/apiConfig.js Normal file
View File

@@ -0,0 +1,169 @@
import { state } from '../state/index.js';
/**
* API Configuration
* Centralized configuration for all model types and their endpoints
*/
// Model type definitions
export const MODEL_TYPES = {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings' // Future model type
};
// Base API configuration for each model type
export const MODEL_CONFIG = {
[MODEL_TYPES.LORA]: {
displayName: 'LoRA',
singularName: 'lora',
defaultPageSize: 100,
supportsLetterFilter: true,
supportsBulkOperations: true,
supportsMove: true,
templateName: 'loras.html'
},
[MODEL_TYPES.CHECKPOINT]: {
displayName: 'Checkpoint',
singularName: 'checkpoint',
defaultPageSize: 50,
supportsLetterFilter: false,
supportsBulkOperations: true,
supportsMove: false,
templateName: 'checkpoints.html'
},
[MODEL_TYPES.EMBEDDING]: {
displayName: 'Embedding',
singularName: 'embedding',
defaultPageSize: 100,
supportsLetterFilter: true,
supportsBulkOperations: true,
supportsMove: true,
templateName: 'embeddings.html'
}
};
/**
* Generate API endpoints for a given model type
* @param {string} modelType - The model type (e.g., 'loras', 'checkpoints')
* @returns {Object} Object containing all API endpoints for the model type
*/
export function getApiEndpoints(modelType) {
if (!Object.values(MODEL_TYPES).includes(modelType)) {
throw new Error(`Invalid model type: ${modelType}`);
}
return {
// Base CRUD operations
list: `/api/${modelType}`,
delete: `/api/${modelType}/delete`,
exclude: `/api/${modelType}/exclude`,
rename: `/api/${modelType}/rename`,
save: `/api/${modelType}/save-metadata`,
// Bulk operations
bulkDelete: `/api/${modelType}/bulk-delete`,
// CivitAI integration
fetchCivitai: `/api/${modelType}/fetch-civitai`,
fetchAllCivitai: `/api/${modelType}/fetch-all-civitai`,
relinkCivitai: `/api/${modelType}/relink-civitai`,
civitaiVersions: `/api/${modelType}/civitai/versions`,
// Preview management
replacePreview: `/api/${modelType}/replace-preview`,
// Query operations
scan: `/api/${modelType}/scan`,
topTags: `/api/${modelType}/top-tags`,
baseModels: `/api/${modelType}/base-models`,
roots: `/api/${modelType}/roots`,
folders: `/api/${modelType}/folders`,
duplicates: `/api/${modelType}/find-duplicates`,
conflicts: `/api/${modelType}/find-filename-conflicts`,
verify: `/api/${modelType}/verify-duplicates`,
// Model-specific endpoints (will be merged with specific configs)
specific: {}
};
}
/**
* Model-specific endpoint configurations
*/
export const MODEL_SPECIFIC_ENDPOINTS = {
[MODEL_TYPES.LORA]: {
letterCounts: `/api/${MODEL_TYPES.LORA}/letter-counts`,
notes: `/api/${MODEL_TYPES.LORA}/get-notes`,
triggerWords: `/api/${MODEL_TYPES.LORA}/get-trigger-words`,
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`,
moveModel: `/api/${MODEL_TYPES.LORA}/move_model`,
moveBulk: `/api/${MODEL_TYPES.LORA}/move_models_bulk`,
getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`
},
[MODEL_TYPES.CHECKPOINT]: {
info: `/api/${MODEL_TYPES.CHECKPOINT}/info`
},
[MODEL_TYPES.EMBEDDING]: {
// Future embedding-specific endpoints
}
};
/**
* Get complete API configuration for a model type
* @param {string} modelType - The model type
* @returns {Object} Complete API configuration
*/
export function getCompleteApiConfig(modelType) {
const baseEndpoints = getApiEndpoints(modelType);
const specificEndpoints = MODEL_SPECIFIC_ENDPOINTS[modelType] || {};
const config = MODEL_CONFIG[modelType];
return {
modelType,
config,
endpoints: {
...baseEndpoints,
specific: specificEndpoints
}
};
}
/**
* Validate if a model type is supported
* @param {string} modelType - The model type to validate
* @returns {boolean} True if valid, false otherwise
*/
export function isValidModelType(modelType) {
return Object.values(MODEL_TYPES).includes(modelType);
}
/**
* Get model type from current page or explicit parameter
* @param {string} [explicitType] - Explicitly provided model type
* @returns {string} The model type
*/
export function getCurrentModelType(explicitType = null) {
if (explicitType && isValidModelType(explicitType)) {
return explicitType;
}
return state.currentPageType || MODEL_TYPES.LORA;
}
// Download API endpoints (shared across all model types)
export const DOWNLOAD_ENDPOINTS = {
download: '/api/download-model',
downloadGet: '/api/download-model-get',
cancelGet: '/api/cancel-download-get',
progress: '/api/download-progress'
};
// WebSocket endpoints
export const WS_ENDPOINTS = {
fetchProgress: '/ws/fetch-progress'
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,165 +1,43 @@
import {
fetchModelsPage,
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll,
refreshModels as baseRefreshModels,
deleteModel as baseDeleteModel,
replaceModelPreview,
fetchCivitaiMetadata,
refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js';
import { state } from '../state/index.js';
import { createModelApiClient } from './baseModelApi.js';
import { MODEL_TYPES } from './apiConfig.js';
/**
* Fetch checkpoints with pagination for virtual scrolling
* @param {number} page - Page number to fetch
* @param {number} pageSize - Number of items per page
* @returns {Promise<Object>} Object containing items, total count, and pagination info
*/
export async function fetchCheckpointsPage(page = 1, pageSize = 100) {
return fetchModelsPage({
modelType: 'checkpoint',
page,
pageSize,
endpoint: '/api/checkpoints'
});
}
// Create Checkpoint-specific API client
const checkpointApiClient = createModelApiClient(MODEL_TYPES.CHECKPOINT);
/**
* Load more checkpoints with pagination - updated to work with VirtualScroller
* @param {boolean} resetPage - Whether to reset to the first page
* @param {boolean} updateFolders - Whether to update folder tags
* @returns {Promise<void>}
*/
// Export all common operations using the unified client
export const deleteModel = (filePath) => checkpointApiClient.deleteModel(filePath);
export const excludeCheckpoint = (filePath) => checkpointApiClient.excludeModel(filePath);
export const renameCheckpointFile = (filePath, newFileName) => checkpointApiClient.renameModelFile(filePath, newFileName);
export const replacePreview = (filePath) => checkpointApiClient.replaceModelPreview(filePath);
export const saveModelMetadata = (filePath, data) => checkpointApiClient.saveModelMetadata(filePath, data);
export const refreshCheckpoints = (fullRebuild = false) => checkpointApiClient.refreshModels(fullRebuild);
export const refreshSingleCheckpointMetadata = (filePath) => checkpointApiClient.refreshSingleModelMetadata(filePath);
export const fetchCivitai = (resetAndReloadFunction) => checkpointApiClient.fetchCivitaiMetadata(resetAndReloadFunction);
// Pagination functions
export const fetchCheckpointsPage = (page = 1, pageSize = 50) => checkpointApiClient.fetchModelsPage(page, pageSize);
// Virtual scrolling operations
export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) {
return loadMoreWithVirtualScroll({
modelType: 'checkpoint',
resetPage,
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
return checkpointApiClient.loadMoreWithVirtualScroll(resetPage, updateFolders);
}
// Reset and reload checkpoints
export async function resetAndReload(updateFolders = false) {
return resetAndReloadWithVirtualScroll({
modelType: 'checkpoint',
updateFolders,
fetchPageFunction: fetchCheckpointsPage
});
return checkpointApiClient.resetAndReloadWithVirtualScroll(updateFolders);
}
// Refresh checkpoints
export async function refreshCheckpoints(fullRebuild = false) {
return baseRefreshModels({
modelType: 'checkpoint',
scanEndpoint: '/api/checkpoints/scan',
resetAndReloadFunction: resetAndReload,
fullRebuild: fullRebuild
});
}
// Delete a checkpoint
export function deleteCheckpoint(filePath) {
return baseDeleteModel(filePath, 'checkpoint');
}
// Replace checkpoint preview
export function replaceCheckpointPreview(filePath) {
return replaceModelPreview(filePath, 'checkpoint');
}
// Fetch metadata from Civitai for checkpoints
export async function fetchCivitai() {
return fetchCivitaiMetadata({
modelType: 'checkpoint',
fetchEndpoint: '/api/checkpoints/fetch-all-civitai',
resetAndReloadFunction: resetAndReload
});
}
// Refresh single checkpoint metadata
export async function refreshSingleCheckpointMetadata(filePath) {
await refreshSingleModelMetadata(filePath, 'checkpoint');
}
/**
* Save model metadata to the server
* @param {string} filePath - Path to the model file
* @param {Object} data - Metadata to save
* @returns {Promise} - Promise that resolves with the server response
*/
export async function saveModelMetadata(filePath, data) {
// Checkpoint-specific functions
export async function getCheckpointInfo(name) {
try {
// Show loading indicator
state.loadingManager.showSimpleLoading('Saving metadata...');
const response = await fetch('/api/checkpoints/save-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
// Update the virtual scroller with the new metadata
state.virtualScroller.updateSingleItem(filePath, data);
return response.json();
} finally {
// Always hide the loading indicator when done
state.loadingManager.hide();
}
}
/**
* Exclude a checkpoint model from being shown in the UI
* @param {string} filePath - File path of the checkpoint to exclude
* @returns {Promise<boolean>} Promise resolving to success status
*/
export function excludeCheckpoint(filePath) {
return baseExcludeModel(filePath, 'checkpoint');
}
/**
* Rename a checkpoint file
* @param {string} filePath - Current file path
* @param {string} newFileName - New file name (without path)
* @returns {Promise<Object>} - Promise that resolves with the server response
*/
export async function renameCheckpointFile(filePath, newFileName) {
try {
// Show loading indicator
state.loadingManager.showSimpleLoading('Renaming checkpoint file...');
const response = await fetch('/api/checkpoints/rename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
new_file_name: newFileName
})
});
const response = await fetch(`${checkpointApiClient.apiConfig.endpoints.specific.info}/${encodeURIComponent(name)}`);
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
throw new Error(`Failed to fetch checkpoint info: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error renaming checkpoint file:', error);
console.error('Error fetching checkpoint info:', error);
throw error;
} finally {
state.loadingManager.hide();
}
}

View File

@@ -1,131 +1,35 @@
import {
fetchModelsPage,
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll,
refreshModels as baseRefreshModels,
deleteModel as baseDeleteModel,
replaceModelPreview,
fetchCivitaiMetadata,
refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js';
import { state } from '../state/index.js';
import { createModelApiClient } from './baseModelApi.js';
import { MODEL_TYPES } from './apiConfig.js';
/**
* Save model metadata to the server
* @param {string} filePath - File path
* @param {Object} data - Data to save
* @returns {Promise} Promise of the save operation
*/
export async function saveModelMetadata(filePath, data) {
try {
// Show loading indicator
state.loadingManager.showSimpleLoading('Saving metadata...');
const response = await fetch('/api/loras/save-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
// Create LoRA-specific API client
const loraApiClient = createModelApiClient(MODEL_TYPES.LORA);
if (!response.ok) {
throw new Error('Failed to save metadata');
}
// Export all common operations using the unified client
export const deleteModel = (filePath) => loraApiClient.deleteModel(filePath);
export const excludeLora = (filePath) => loraApiClient.excludeModel(filePath);
export const renameLoraFile = (filePath, newFileName) => loraApiClient.renameModelFile(filePath, newFileName);
export const replacePreview = (filePath) => loraApiClient.replaceModelPreview(filePath);
export const saveModelMetadata = (filePath, data) => loraApiClient.saveModelMetadata(filePath, data);
export const refreshLoras = (fullRebuild = false) => loraApiClient.refreshModels(fullRebuild);
export const refreshSingleLoraMetadata = (filePath) => loraApiClient.refreshSingleModelMetadata(filePath);
export const fetchCivitai = (resetAndReloadFunction) => loraApiClient.fetchCivitaiMetadata(resetAndReloadFunction);
// Update the virtual scroller with the new data
state.virtualScroller.updateSingleItem(filePath, data);
return response.json();
} finally {
// Always hide the loading indicator when done
state.loadingManager.hide();
}
}
// Pagination functions
export const fetchLorasPage = (page = 1, pageSize = 100) => loraApiClient.fetchModelsPage(page, pageSize);
/**
* Exclude a lora model from being shown in the UI
* @param {string} filePath - File path of the model to exclude
* @returns {Promise<boolean>} Promise resolving to success status
*/
export async function excludeLora(filePath) {
return baseExcludeModel(filePath, 'lora');
}
/**
* Load more loras with pagination - updated to work with VirtualScroller
* @param {boolean} resetPage - Whether to reset to the first page
* @param {boolean} updateFolders - Whether to update folder tags
* @returns {Promise<void>}
*/
// Virtual scrolling operations
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
return loadMoreWithVirtualScroll({
modelType: 'lora',
resetPage,
updateFolders,
fetchPageFunction: fetchLorasPage
});
}
/**
* Fetch loras with pagination for virtual scrolling
* @param {number} page - Page number to fetch
* @param {number} pageSize - Number of items per page
* @returns {Promise<Object>} Object containing items, total count, and pagination info
*/
export async function fetchLorasPage(page = 1, pageSize = 100) {
return fetchModelsPage({
modelType: 'lora',
page,
pageSize,
endpoint: '/api/loras'
});
}
export async function fetchCivitai() {
return fetchCivitaiMetadata({
modelType: 'lora',
fetchEndpoint: '/api/loras/fetch-all-civitai',
resetAndReloadFunction: resetAndReload
});
}
export async function deleteModel(filePath) {
return baseDeleteModel(filePath, 'lora');
}
export async function replacePreview(filePath) {
return replaceModelPreview(filePath, 'lora');
return loraApiClient.loadMoreWithVirtualScroll(resetPage, updateFolders);
}
export async function resetAndReload(updateFolders = false) {
return resetAndReloadWithVirtualScroll({
modelType: 'lora',
updateFolders,
fetchPageFunction: fetchLorasPage
});
}
export async function refreshLoras(fullRebuild = false) {
return baseRefreshModels({
modelType: 'lora',
scanEndpoint: '/api/loras/scan',
resetAndReloadFunction: resetAndReload,
fullRebuild: fullRebuild
});
}
export async function refreshSingleLoraMetadata(filePath) {
await refreshSingleModelMetadata(filePath, 'lora');
return loraApiClient.resetAndReloadWithVirtualScroll(updateFolders);
}
// LoRA-specific functions that don't have common equivalents
export async function fetchModelDescription(modelId, filePath) {
try {
const response = await fetch(`/api/loras/model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`);
const response = await fetch(`${loraApiClient.apiConfig.endpoints.specific.modelDescription}?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`);
if (!response.ok) {
throw new Error(`Failed to fetch model description: ${response.statusText}`);
@@ -138,38 +42,47 @@ export async function fetchModelDescription(modelId, filePath) {
}
}
/**
* Rename a LoRA file
* @param {string} filePath - Current file path
* @param {string} newFileName - New file name (without path)
* @returns {Promise<Object>} - Promise that resolves with the server response
*/
export async function renameLoraFile(filePath, newFileName) {
// Move operations (LoRA-specific)
export async function moveModel(filePath, targetPath) {
try {
// Show loading indicator
state.loadingManager.showSimpleLoading('Renaming LoRA file...');
const response = await fetch('/api/loras/rename', {
const response = await fetch(loraApiClient.apiConfig.endpoints.specific.moveModel, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: filePath,
new_file_name: newFileName
target_path: targetPath
})
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
throw new Error('Failed to move model');
}
return await response.json();
} catch (error) {
console.error('Error renaming LoRA file:', error);
console.error('Error moving model:', error);
throw error;
}
}
export async function moveModelsBulk(filePaths, targetPath) {
try {
const response = await fetch(loraApiClient.apiConfig.endpoints.specific.moveBulk, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_paths: filePaths,
target_path: targetPath
})
});
if (!response.ok) {
throw new Error('Failed to move models');
}
return await response.json();
} catch (error) {
console.error('Error moving models in bulk:', error);
throw error;
} finally {
// Hide loading indicator
state.loadingManager.hide();
}
}

View File

@@ -1,8 +1,4 @@
import { RecipeCard } from '../components/RecipeCard.js';
import {
resetAndReloadWithVirtualScroll,
loadMoreWithVirtualScroll
} from './baseModelApi.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
@@ -98,6 +94,98 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
}
}
/**
* Reset and reload models using virtual scrolling
* @param {Object} options - Operation options
* @returns {Promise<Object>} The fetch result
*/
export async function resetAndReloadWithVirtualScroll(options = {}) {
const {
modelType = 'lora',
updateFolders = false,
fetchPageFunction
} = options;
const pageState = getCurrentPageState();
try {
pageState.isLoading = true;
// Reset page counter
pageState.currentPage = 1;
// Fetch the first page
const result = await fetchPageFunction(1, pageState.pageSize || 50);
// Update the virtual scroller
state.virtualScroller.refreshWithData(
result.items,
result.totalItems,
result.hasMore
);
// Update state
pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page will be 2
return result;
} catch (error) {
console.error(`Error reloading ${modelType}s:`, error);
showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error');
throw error;
} finally {
pageState.isLoading = false;
}
}
/**
* Load more models using virtual scrolling
* @param {Object} options - Operation options
* @returns {Promise<Object>} The fetch result
*/
export async function loadMoreWithVirtualScroll(options = {}) {
const {
modelType = 'lora',
resetPage = false,
updateFolders = false,
fetchPageFunction
} = options;
const pageState = getCurrentPageState();
try {
// Start loading state
pageState.isLoading = true;
// Reset to first page if requested
if (resetPage) {
pageState.currentPage = 1;
}
// Fetch the first page of data
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
// Update virtual scroller with the new data
state.virtualScroller.refreshWithData(
result.items,
result.totalItems,
result.hasMore
);
// Update state
pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page to load would be 2
return result;
} catch (error) {
console.error(`Error loading ${modelType}s:`, error);
showToast(`Failed to load ${modelType}s: ${error.message}`, 'error');
throw error;
} finally {
pageState.isLoading = false;
}
}
/**
* Reset and reload recipes using virtual scrolling
* @param {boolean} updateFolders - Whether to update folder tags