mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-22 11:21:15 -03:00
Compare commits
6 Commits
ccf1c6f2ae
...
df67bd396a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df67bd396a | ||
|
|
dd5d9cfcb2 | ||
|
|
d9fd60bec1 | ||
|
|
b633b22779 | ||
|
|
1ffa543160 | ||
|
|
cdc940586e |
@@ -461,7 +461,11 @@ class RecipeQueryHandler:
|
|||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
self._logger.info("Manually triggering recipe cache rebuild")
|
full_rebuild = request.query.get("full_rebuild", "true").lower() == "true"
|
||||||
|
self._logger.info(
|
||||||
|
"Manually triggering recipe cache %s",
|
||||||
|
"full rebuild" if full_rebuild else "refresh",
|
||||||
|
)
|
||||||
await recipe_scanner.get_cached_data(force_refresh=True)
|
await recipe_scanner.get_cached_data(force_refresh=True)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "message": "Recipe cache refreshed successfully"}
|
{"success": True, "message": "Recipe cache refreshed successfully"}
|
||||||
|
|||||||
@@ -186,6 +186,22 @@ class CivArchiveClient:
|
|||||||
if "metadata" in file_data:
|
if "metadata" in file_data:
|
||||||
transformed["metadata"] = file_data["metadata"]
|
transformed["metadata"] = file_data["metadata"]
|
||||||
|
|
||||||
|
# Infer metadata.format from filename extension
|
||||||
|
name = transformed.get("name")
|
||||||
|
if name and isinstance(name, str):
|
||||||
|
lower_name = name.lower()
|
||||||
|
if lower_name.endswith(".safetensors"):
|
||||||
|
inferred_format = "SafeTensor"
|
||||||
|
elif lower_name.endswith(".ckpt"):
|
||||||
|
inferred_format = "PickleTensor"
|
||||||
|
else:
|
||||||
|
inferred_format = None
|
||||||
|
if inferred_format:
|
||||||
|
if "metadata" not in transformed:
|
||||||
|
transformed["metadata"] = {}
|
||||||
|
if isinstance(transformed["metadata"], dict):
|
||||||
|
transformed["metadata"].setdefault("format", inferred_format)
|
||||||
|
|
||||||
if file_data.get("modelVersionId") is not None:
|
if file_data.get("modelVersionId") is not None:
|
||||||
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||||
elif file_data.get("model_version_id") is not None:
|
elif file_data.get("model_version_id") is not None:
|
||||||
@@ -213,6 +229,20 @@ class CivArchiveClient:
|
|||||||
for file_data in candidates:
|
for file_data in candidates:
|
||||||
if isinstance(file_data, dict):
|
if isinstance(file_data, dict):
|
||||||
transformed_files.append(self._transform_file_entry(file_data))
|
transformed_files.append(self._transform_file_entry(file_data))
|
||||||
|
|
||||||
|
# Sort: .safetensors first, .ckpt second, others last
|
||||||
|
# so the backend fallback (no file_params) prefers safetensors
|
||||||
|
def _sort_key(f: Dict) -> int:
|
||||||
|
fname = f.get("name") or ""
|
||||||
|
if isinstance(fname, str):
|
||||||
|
lower = fname.lower()
|
||||||
|
if lower.endswith(".safetensors"):
|
||||||
|
return 0
|
||||||
|
elif lower.endswith(".ckpt"):
|
||||||
|
return 1
|
||||||
|
return 2
|
||||||
|
|
||||||
|
transformed_files.sort(key=_sort_key)
|
||||||
return transformed_files
|
return transformed_files
|
||||||
|
|
||||||
def _transform_version(
|
def _transform_version(
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
// Reset page counter
|
// Reset page counter
|
||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
|
|
||||||
// Fetch the first page
|
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
|
||||||
const result = await fetchPageFunction(1, pageState.pageSize || 50);
|
const result = await fetchPageFunction(1, pageSize);
|
||||||
|
|
||||||
// Update the virtual scroller
|
// Update the virtual scroller
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
@@ -251,8 +251,8 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the first page of data
|
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
|
||||||
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
|
const result = await fetchPageFunction(pageState.currentPage, pageSize);
|
||||||
|
|
||||||
// Update virtual scroller with the new data
|
// Update virtual scroller with the new data
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
@@ -294,47 +294,41 @@ export async function resetAndReload(updateFolders = false, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync changes - quick refresh without rebuilding cache (similar to models page)
|
* Refreshes the recipe list by triggering a backend scan, then reloading.
|
||||||
|
* @param {boolean} fullRebuild - If true, fully rebuild the cache; if false, incremental scan
|
||||||
*/
|
*/
|
||||||
export async function syncChanges() {
|
export async function syncChanges() {
|
||||||
try {
|
return refreshRecipes(false);
|
||||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
|
||||||
|
|
||||||
// Simply reload the recipes without rebuilding cache
|
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
|
||||||
|
|
||||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error syncing recipes:', error);
|
|
||||||
showToast('toast.recipes.syncFailed', { message: error.message }, 'error');
|
|
||||||
} finally {
|
|
||||||
state.loadingManager.hide();
|
|
||||||
state.loadingManager.restoreProgressBar();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function refreshRecipes(fullRebuild = true) {
|
||||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes';
|
||||||
*/
|
const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh';
|
||||||
export async function refreshRecipes() {
|
|
||||||
try {
|
|
||||||
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
|
||||||
|
|
||||||
// Call the API endpoint to rebuild the recipe cache
|
try {
|
||||||
const response = await fetch(RECIPE_ENDPOINTS.scan);
|
state.loadingManager.show(`${actionLabel}...`, 0);
|
||||||
|
|
||||||
|
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
|
||||||
|
url.searchParams.append('full_rebuild', fullRebuild);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error(`Failed to refresh recipe cache: ${response.status} ${response.statusText}`);
|
||||||
throw new Error(data.error || 'Failed to refresh recipe cache');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// After successful cache rebuild, reload the recipes
|
const data = await response.json();
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
if (data.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
await resetAndReload(false);
|
||||||
|
|
||||||
|
showToast('toast.api.refreshComplete', { action: actionToast }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing recipes:', error);
|
console.error('Error refreshing recipes:', error);
|
||||||
showToast('toast.recipes.refreshFailed', { message: error.message }, 'error');
|
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: 'recipe' }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
state.loadingManager.restoreProgressBar();
|
state.loadingManager.restoreProgressBar();
|
||||||
|
|||||||
@@ -306,8 +306,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.repaired > 0) {
|
if (result.repaired > 0) {
|
||||||
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
||||||
// Refresh the current card or reload
|
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
this.resetAndReload();
|
if (detailResponse.ok) {
|
||||||
|
const updatedRecipe = await detailResponse.json();
|
||||||
|
const filePath = this.currentCard?.dataset?.filepath;
|
||||||
|
if (filePath && state.virtualScroller) {
|
||||||
|
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class RecipeCard {
|
|||||||
card.dataset.created = this.recipe.created_date;
|
card.dataset.created = this.recipe.created_date;
|
||||||
card.dataset.id = this.recipe.id || '';
|
card.dataset.id = this.recipe.id || '';
|
||||||
card.dataset.folder = this.recipe.folder || '';
|
card.dataset.folder = this.recipe.folder || '';
|
||||||
|
card.dataset.favorite = this.recipe.favorite ? 'true' : 'false';
|
||||||
|
|
||||||
// Get base model with fallback
|
// Get base model with fallback
|
||||||
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
||||||
@@ -161,6 +162,7 @@ class RecipeCard {
|
|||||||
|
|
||||||
// Update early to provide instant feedback and avoid race conditions with re-renders
|
// Update early to provide instant feedback and avoid race conditions with re-renders
|
||||||
this.recipe.favorite = newFavoriteState;
|
this.recipe.favorite = newFavoriteState;
|
||||||
|
card.dataset.favorite = newFavoriteState ? 'true' : 'false';
|
||||||
|
|
||||||
// Function to update icon state
|
// Function to update icon state
|
||||||
const updateIconUI = (icon, state) => {
|
const updateIconUI = (icon, state) => {
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ export class BatchImportManager {
|
|||||||
|
|
||||||
// Refresh recipes list to show newly imported recipes
|
// Refresh recipes list to show newly imported recipes
|
||||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show results step
|
// Show results step
|
||||||
|
|||||||
@@ -309,9 +309,22 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
}, 'warning');
|
}, 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the recipes list to update LoRA status
|
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
|
||||||
if (window.recipeManager) {
|
if (state.virtualScroller) {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
const { extractRecipeId } = await import('../api/recipeApi.js');
|
||||||
|
for (const recipe of this.pendingRecipes) {
|
||||||
|
const recipeId = extractRecipeId(recipe.file_path);
|
||||||
|
if (!recipeId) continue;
|
||||||
|
try {
|
||||||
|
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
|
||||||
|
if (detailRes.ok) {
|
||||||
|
const updated = await detailRes.json();
|
||||||
|
state.virtualScroller.updateSingleItem(recipe.file_path, updated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to update recipe card after LoRA download:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -662,7 +662,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({ preserveScroll: 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') {
|
||||||
// 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);
|
||||||
@@ -746,7 +746,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({ preserveScroll: 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') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,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({ preserveScroll: 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') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
|
|||||||
@@ -2876,7 +2876,7 @@ export class SettingsManager {
|
|||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} else if (this.currentPage === 'recipes') {
|
} else if (this.currentPage === 'recipes') {
|
||||||
// Reload the recipes without updating folders
|
// Reload the recipes without updating folders
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class DownloadManager {
|
|||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
|
||||||
// Refresh the recipe
|
// Refresh the recipe
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
|||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes, syncChanges, RecipeSidebarApiClient } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
import { sidebarManager } from './components/SidebarManager.js';
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
|
|
||||||
class RecipePageControls {
|
class RecipePageControls {
|
||||||
@@ -19,16 +19,13 @@ class RecipePageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
await refreshVirtualScroll({ preserveScroll: true });
|
await refreshVirtualScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
if (fullRebuild) {
|
await refreshRecipes(fullRebuild);
|
||||||
await refreshRecipes();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncChanges();
|
await sidebarManager.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSidebarApiClient() {
|
getSidebarApiClient() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|||||||
const showToastMock = vi.hoisted(() => vi.fn());
|
const showToastMock = vi.hoisted(() => vi.fn());
|
||||||
const loadingManagerMock = vi.hoisted(() => ({
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
showSimpleLoading: vi.fn(),
|
showSimpleLoading: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
restoreProgressBar: vi.fn(),
|
restoreProgressBar: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -177,9 +178,7 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves scroll position for recipe reloads when requested', async () => {
|
it('reloads recipes without preserving scroll', async () => {
|
||||||
const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 };
|
|
||||||
captureScrollPositionMock.mockReturnValue(scrollSnapshot);
|
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -189,18 +188,18 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
await resetAndReload(false);
|
||||||
|
|
||||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(captureScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
||||||
[{ id: 'recipe-1' }],
|
[{ id: 'recipe-1' }],
|
||||||
1,
|
1,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot);
|
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses scroll-preserving reloads for syncChanges', async () => {
|
it('uses scroll-free reloads for syncChanges', async () => {
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -212,8 +211,8 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
|
|
||||||
await syncChanges();
|
await syncChanges();
|
||||||
|
|
||||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(captureScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user