feat: add folder-based recipe organization and navigation

- Add new API endpoints for folder operations: get_folders, get_folder_tree, and get_unified_folder_tree
- Extend recipe listing handler to support folder and recursive filtering parameters
- Register new folder-related routes in route definitions
- Enable users to organize and browse recipes using folder structures
This commit is contained in:
Will Miao
2025-11-25 11:10:58 +08:00
parent dd89aa49c1
commit 67fb205b43
9 changed files with 303 additions and 21 deletions

View File

@@ -2,6 +2,24 @@ import { RecipeCard } from '../components/RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
const RECIPE_ENDPOINTS = {
list: '/api/lm/recipes',
detail: '/api/lm/recipe',
scan: '/api/lm/recipes/scan',
update: '/api/lm/recipe',
folders: '/api/lm/recipes/folders',
folderTree: '/api/lm/recipes/folder-tree',
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
};
const RECIPE_SIDEBAR_CONFIG = {
config: {
displayName: 'Recipes',
supportsMove: false,
},
endpoints: RECIPE_ENDPOINTS,
};
/**
* Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch
@@ -17,11 +35,18 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
page_size: pageSize || pageState.pageSize || 20,
sort_by: pageState.sortBy
});
if (pageState.activeFolder) {
params.append('folder', pageState.activeFolder);
params.append('recursive', pageState.searchOptions?.recursive !== false);
} else if (pageState.searchOptions?.recursive !== undefined) {
params.append('recursive', pageState.searchOptions.recursive);
}
// If we have a specific recipe ID to load
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// Special case: load specific recipe
const response = await fetch(`/api/lm/recipe/${pageState.customFilter.recipeId}`);
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`);
@@ -78,7 +103,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
}
// Fetch recipes
const response = await fetch(`/api/lm/recipes?${params.toString()}`);
const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to load recipes: ${response.statusText}`);
@@ -213,7 +238,7 @@ export async function refreshRecipes() {
state.loadingManager.showSimpleLoading('Refreshing recipes...');
// Call the API endpoint to rebuild the recipe cache
const response = await fetch('/api/lm/recipes/scan');
const response = await fetch(RECIPE_ENDPOINTS.scan);
if (!response.ok) {
const data = await response.json();
@@ -280,7 +305,7 @@ export async function updateRecipeMetadata(filePath, updates) {
const basename = filePath.split('/').pop().split('\\').pop();
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
const response = await fetch(`/api/lm/recipe/${recipeId}/update`, {
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -306,3 +331,33 @@ export async function updateRecipeMetadata(filePath, updates) {
state.loadingManager.hide();
}
}
export class RecipeSidebarApiClient {
constructor() {
this.apiConfig = RECIPE_SIDEBAR_CONFIG;
}
async fetchUnifiedFolderTree() {
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
if (!response.ok) {
throw new Error('Failed to fetch recipe folder tree');
}
return response.json();
}
async fetchModelFolders() {
const response = await fetch(this.apiConfig.endpoints.folders);
if (!response.ok) {
throw new Error('Failed to fetch recipe folders');
}
return response.json();
}
async moveBulkModels() {
throw new Error('Recipe move operations are not supported.');
}
async moveSingleModel() {
throw new Error('Recipe move operations are not supported.');
}
}

View File

@@ -77,7 +77,9 @@ export class SidebarManager {
this.pageControls = pageControls;
this.pageType = pageControls.pageType;
this.lastPageControls = pageControls;
this.apiClient = getModelApiClient();
this.apiClient = pageControls?.getSidebarApiClient?.()
|| pageControls?.sidebarApiClient
|| getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();
@@ -205,6 +207,10 @@ export class SidebarManager {
}
initializeDragAndDrop() {
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
return;
}
if (!this.dragHandlersInitialized) {
document.addEventListener('dragstart', this.handleCardDragStart);
document.addEventListener('dragend', this.handleCardDragEnd);
@@ -416,7 +422,14 @@ export class SidebarManager {
}
if (!this.apiClient) {
this.apiClient = getModelApiClient();
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
}
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
return false;
}
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
@@ -470,7 +483,9 @@ export class SidebarManager {
}
async init() {
this.apiClient = getModelApiClient();
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();

View File

@@ -2,17 +2,46 @@
import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js';
import { RecipeModal } from './components/RecipeModal.js';
import { getCurrentPageState } from './state/index.js';
import { state, getCurrentPageState } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes } from './api/recipeApi.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
class RecipePageControls {
constructor() {
this.pageType = 'recipes';
this.pageState = getCurrentPageState();
this.sidebarApiClient = new RecipeSidebarApiClient();
}
async resetAndReload() {
refreshVirtualScroll();
}
async refreshModels(fullRebuild = false) {
if (fullRebuild) {
await refreshRecipes();
return;
}
refreshVirtualScroll();
}
getSidebarApiClient() {
return this.sidebarApiClient;
}
}
class RecipeManager {
constructor() {
// Get page state
this.pageState = getCurrentPageState();
// Page controls for shared sidebar behaviors
this.pageControls = new RecipePageControls();
// Initialize ImportManager
this.importManager = new ImportManager();
@@ -51,10 +80,23 @@ class RecipeManager {
// Expose necessary functions to the page
this._exposeGlobalFunctions();
// Initialize sidebar navigation
await this._initSidebar();
// Initialize common page features
appCore.initializePageFeatures();
}
async _initSidebar() {
try {
sidebarManager.setHostPageControls(this.pageControls);
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
await sidebarManager.setSidebarEnabled(shouldShowSidebar);
} catch (error) {
console.error('Failed to initialize recipe sidebar:', error);
}
}
_initSearchOptions() {
// Ensure recipes search options are properly initialized
@@ -63,7 +105,8 @@ class RecipeManager {
title: true, // Recipe title
tags: true, // Recipe tags
loraName: true, // LoRA file name
loraModel: true // LoRA model name
loraModel: true, // LoRA model name
recursive: true
};
}
}

View File

@@ -96,12 +96,14 @@ export const state = {
isLoading: false,
hasMore: true,
sortBy: 'date',
activeFolder: getStorageItem('recipes_activeFolder'),
searchManager: null,
searchOptions: {
title: true,
tags: true,
loraName: true,
loraModel: true
loraModel: true,
recursive: getStorageItem('recipes_recursiveSearch', true),
},
filters: {
baseModel: [],