Compare commits

...

6 Commits

Author SHA1 Message Date
Will Miao
df67bd396a fix(recipe): re-export syncChanges and add show mock to fix test 2026-06-02 11:02:20 +08:00
Will Miao
dd5d9cfcb2 fix(recipe): align refresh split button behavior with models page
- refreshRecipes() now accepts fullRebuild param and passes it to scan endpoint
- Use consistent toast.api.refreshComplete / toast.api.refreshFailed keys
- Use loadingManager.show() with progress bar (matching models page style)
- Both Refresh and Rebuild Cache now hit the real /api/lm/recipes/scan endpoint
- Add sidebarManager.refresh() after recipe scan completes
- Backend scan_recipes handler reads full_rebuild query param
2026-06-02 09:50:59 +08:00
Will Miao
d9fd60bec1 fix(recipe): use VirtualScroller pageSize in reload helpers to prevent pagination offset gap 2026-06-02 08:43:30 +08:00
Will Miao
b633b22779 fix(recipe): prevent empty grid by removing preserveScroll from refresh triggers
Bug: when scrolling down on recipes page, any operation with
preserveScroll: true would fetch only page 1 data then restore
scroll position to beyond the loaded items, leaving the grid empty.

Fix:
- Remove preserveScroll: true from all 7 must-refresh trigger
  paths (filter, search, sort, import, settings reload, sync,
  rebuild cache, sidebar folder nav)
- Replace full list refresh with updateSingleItem() for repair
  and bulk missing-LoRA download operations
- Update tests to match new scroll-free behavior
2026-06-02 08:15:29 +08:00
Will Miao
1ffa543160 fix(recipe): set dataset.favorite on recipe cards for correct bulk favorite menu 2026-06-02 07:06:58 +08:00
Will Miao
cdc940586e fix(civarchive): infer metadata.format from extension and prioritize safetensors in file list 2026-06-01 22:07:55 +08:00
13 changed files with 106 additions and 61 deletions

View File

@@ -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"}

View File

@@ -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(

View File

@@ -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();

View File

@@ -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');
} }

View File

@@ -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) => {

View File

@@ -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

View File

@@ -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);
}
}
} }
} }

View File

@@ -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);
} }

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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);
}); });
}); });