Enhance LoRA and Recipe templates by adding request context to template rendering. Update JavaScript to initialize search managers with context-specific options and improve header navigation with dynamic search placeholders. Refactor header component for better context awareness in search functionality.

This commit is contained in:
Will Miao
2025-03-17 10:11:50 +08:00
parent b0a8b0cc6f
commit 1034282161
8 changed files with 631 additions and 37 deletions

View File

@@ -71,7 +71,8 @@ class LoraRoutes:
rendered = template.render(
folders=[], # 空文件夹列表
is_initializing=True, # 新增标志
settings=settings # Pass settings to template
settings=settings, # Pass settings to template
request=request # Pass the request object to the template
)
else:
# 正常流程
@@ -80,7 +81,8 @@ class LoraRoutes:
rendered = template.render(
folders=cache.folders,
is_initializing=False,
settings=settings # Pass settings to template
settings=settings, # Pass settings to template
request=request # Pass the request object to the template
)
return web.Response(
@@ -110,7 +112,8 @@ class LoraRoutes:
template = self.template_env.get_template('recipes.html')
rendered = template.render(
is_initializing=True,
settings=settings
settings=settings,
request=request # Pass the request object to the template
)
else:
# Normal flow - get recipes with the same formatting as the API endpoint
@@ -137,7 +140,8 @@ class LoraRoutes:
rendered = template.render(
recipes=recipes_data,
is_initializing=False,
settings=settings
settings=settings,
request=request # Pass the request object to the template
)
return web.Response(

View File

@@ -22,7 +22,7 @@ import {
} from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { showDeleteModal, confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
import { SearchManager } from './utils/search.js';
import { SearchManager } from './managers/SearchManager.js';
import { DownloadManager } from './managers/DownloadManager.js';
import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js';
import { LoraContextMenu } from './components/ContextMenu.js';
@@ -104,6 +104,22 @@ document.addEventListener('DOMContentLoaded', async () => {
// Update positions on window resize
window.addEventListener('resize', updatePanelPositions);
// Initialize search manager with LoRA-specific options
const loraSearchManager = new SearchManager({
searchCallback: (query, options, recursive) => {
// LoRA-specific search implementation
// This could call your API with the right parameters
fetchLoras({
search: query,
search_options: options,
recursive: recursive
});
}
});
// Set the current page for proper context
document.body.dataset.page = 'loras';
});
// Initialize event listeners

View File

@@ -0,0 +1,360 @@
import { showToast } from '../utils/uiHelpers.js';
export class RecipeFilterManager {
constructor() {
this.filters = {
baseModel: [],
tags: []
};
this.filterPanel = document.getElementById('filterPanel');
this.filterButton = document.getElementById('filterButton');
this.activeFiltersCount = document.getElementById('activeFiltersCount');
this.tagsLoaded = false;
this.initialize();
}
initialize() {
// Create base model filter tags if they exist
if (document.getElementById('baseModelTags')) {
this.createBaseModelTags();
}
// Close filter panel when clicking outside
document.addEventListener('click', (e) => {
if (!this.filterPanel.contains(e.target) &&
e.target !== this.filterButton &&
!this.filterButton.contains(e.target) &&
!this.filterPanel.classList.contains('hidden')) {
this.closeFilterPanel();
}
});
// Add click handler for filter button
if (this.filterButton) {
this.filterButton.addEventListener('click', () => {
this.toggleFilterPanel();
});
}
// Initialize active filters from localStorage if available
this.loadFiltersFromStorage();
}
async loadTopTags() {
try {
// Show loading state
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
}
const response = await fetch('/api/recipes/top-tags?limit=20');
if (!response.ok) throw new Error('Failed to fetch recipe tags');
const data = await response.json();
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
// After creating tag elements, mark any previously selected ones
this.updateTagSelections();
} else {
throw new Error('Invalid response format');
}
} catch (error) {
console.error('Error loading top recipe tags:', error);
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-error">Failed to load tags</div>';
}
}
}
createTagFilterElements(tags) {
const tagsContainer = document.getElementById('modelTagsFilter');
if (!tagsContainer) return;
tagsContainer.innerHTML = '';
if (!tags.length) {
tagsContainer.innerHTML = '<div class="no-tags">No recipe tags available</div>';
return;
}
tags.forEach(tag => {
const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter';
const tagName = tag.tag;
tagEl.dataset.tag = tagName;
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
// Add click handler to toggle selection and automatically apply
tagEl.addEventListener('click', async () => {
tagEl.classList.toggle('active');
if (tagEl.classList.contains('active')) {
if (!this.filters.tags.includes(tagName)) {
this.filters.tags.push(tagName);
}
} else {
this.filters.tags = this.filters.tags.filter(t => t !== tagName);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
tagsContainer.appendChild(tagEl);
});
}
createBaseModelTags() {
const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return;
// Fetch base models used in recipes
fetch('/api/recipes/base-models')
.then(response => response.json())
.then(data => {
if (data.success && data.base_models) {
baseModelTagsContainer.innerHTML = '';
data.base_models.forEach(model => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag`;
tag.dataset.baseModel = model.name;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
// Update selections based on stored filters
this.updateTagSelections();
}
})
.catch(error => {
console.error('Error fetching base models:', error);
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
});
}
toggleFilterPanel() {
if (this.filterPanel) {
const isHidden = this.filterPanel.classList.contains('hidden');
if (isHidden) {
// Update panel positions before showing
if (window.searchManager && typeof window.searchManager.updatePanelPositions === 'function') {
window.searchManager.updatePanelPositions();
} else if (typeof updatePanelPositions === 'function') {
updatePanelPositions();
}
this.filterPanel.classList.remove('hidden');
this.filterButton.classList.add('active');
// Load tags if they haven't been loaded yet
if (!this.tagsLoaded) {
this.loadTopTags();
this.tagsLoaded = true;
}
} else {
this.closeFilterPanel();
}
}
}
closeFilterPanel() {
if (this.filterPanel) {
this.filterPanel.classList.add('hidden');
}
if (this.filterButton) {
this.filterButton.classList.remove('active');
}
}
updateTagSelections() {
// Update base model tags
const baseModelTags = document.querySelectorAll('.base-model-tag');
baseModelTags.forEach(tag => {
const baseModel = tag.dataset.baseModel;
if (this.filters.baseModel.includes(baseModel)) {
tag.classList.add('active');
} else {
tag.classList.remove('active');
}
});
// Update model tags
const modelTags = document.querySelectorAll('.tag-filter');
modelTags.forEach(tag => {
const tagName = tag.dataset.tag;
if (this.filters.tags.includes(tagName)) {
tag.classList.add('active');
} else {
tag.classList.remove('active');
}
});
}
updateActiveFiltersCount() {
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
if (totalActiveFilters > 0) {
this.activeFiltersCount.textContent = totalActiveFilters;
this.activeFiltersCount.style.display = 'inline-flex';
} else {
this.activeFiltersCount.style.display = 'none';
}
}
async applyFilters(showToastNotification = true) {
// Save filters to localStorage
localStorage.setItem('recipeFilters', JSON.stringify(this.filters));
// Reload recipes with filters applied
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
try {
// Show loading state if available
if (window.recipeManager.showLoading) {
window.recipeManager.showLoading();
}
// Apply the filters
await window.recipeManager.loadRecipes(this.filters);
// Hide loading state if available
if (window.recipeManager.hideLoading) {
window.recipeManager.hideLoading();
}
} catch (error) {
console.error('Error applying filters:', error);
// Fallback to page reload
this._applyFiltersByPageReload();
}
} else {
// Fallback to page reload with filter parameters
this._applyFiltersByPageReload();
}
// Update filter button to show active state
if (this.hasActiveFilters()) {
this.filterButton.classList.add('active');
if (showToastNotification) {
const baseModelCount = this.filters.baseModel.length;
const tagsCount = this.filters.tags.length;
let message = '';
if (baseModelCount > 0 && tagsCount > 0) {
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
} else if (baseModelCount > 0) {
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''}`;
} else if (tagsCount > 0) {
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
}
showToast(message, 'success');
}
} else {
this.filterButton.classList.remove('active');
if (showToastNotification) {
showToast('Filters cleared', 'info');
}
}
}
// Add a helper method for page reload fallback
_applyFiltersByPageReload() {
const params = new URLSearchParams();
if (this.filters.baseModel.length > 0) {
params.append('base_models', this.filters.baseModel.join(','));
}
if (this.filters.tags.length > 0) {
params.append('tags', this.filters.tags.join(','));
}
if (params.toString()) {
window.location.href = `/loras/recipes?${params.toString()}`;
} else {
window.location.href = '/loras/recipes';
}
}
async clearFilters() {
// Clear all filters
this.filters = {
baseModel: [],
tags: []
};
// Update UI
this.updateTagSelections();
this.updateActiveFiltersCount();
// Remove from localStorage
localStorage.removeItem('recipeFilters');
// Update UI and reload data
this.filterButton.classList.remove('active');
// Reload recipes without filters
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
await window.recipeManager.loadRecipes();
} else {
window.location.href = '/loras/recipes';
}
showToast('Recipe filters cleared', 'info');
}
loadFiltersFromStorage() {
const savedFilters = localStorage.getItem('recipeFilters');
if (savedFilters) {
try {
const parsedFilters = JSON.parse(savedFilters);
// Ensure backward compatibility with older filter format
this.filters = {
baseModel: parsedFilters.baseModel || [],
tags: parsedFilters.tags || []
};
this.updateTagSelections();
this.updateActiveFiltersCount();
if (this.hasActiveFilters()) {
this.filterButton.classList.add('active');
}
} catch (error) {
console.error('Error loading recipe filters from storage:', error);
}
}
}
hasActiveFilters() {
return this.filters.baseModel.length > 0 || this.filters.tags.length > 0;
}
}

View File

@@ -0,0 +1,154 @@
/**
* SearchManager - Handles search functionality across different pages
* Each page can extend or customize this base functionality
*/
export class SearchManager {
constructor(options = {}) {
this.options = {
searchDelay: 300,
minSearchLength: 2,
...options
};
this.searchInput = document.getElementById('searchInput');
this.searchOptionsToggle = document.getElementById('searchOptionsToggle');
this.searchOptionsPanel = document.getElementById('searchOptionsPanel');
this.closeSearchOptions = document.getElementById('closeSearchOptions');
this.searchOptionTags = document.querySelectorAll('.search-option-tag');
this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle');
this.searchTimeout = null;
this.currentPage = document.body.dataset.page || 'loras';
this.initEventListeners();
this.loadSearchPreferences();
}
initEventListeners() {
// Search input event
if (this.searchInput) {
this.searchInput.addEventListener('input', () => {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => this.performSearch(), this.options.searchDelay);
});
// Clear search with Escape key
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.searchInput.value = '';
this.performSearch();
}
});
}
// Search options toggle
if (this.searchOptionsToggle) {
this.searchOptionsToggle.addEventListener('click', () => {
this.searchOptionsPanel.classList.toggle('hidden');
});
}
// Close search options
if (this.closeSearchOptions) {
this.closeSearchOptions.addEventListener('click', () => {
this.searchOptionsPanel.classList.add('hidden');
});
}
// Search option tags
if (this.searchOptionTags) {
this.searchOptionTags.forEach(tag => {
tag.addEventListener('click', () => {
tag.classList.toggle('active');
this.saveSearchPreferences();
this.performSearch();
});
});
}
// Recursive search toggle
if (this.recursiveSearchToggle) {
this.recursiveSearchToggle.addEventListener('change', () => {
this.saveSearchPreferences();
this.performSearch();
});
}
}
loadSearchPreferences() {
try {
const preferences = JSON.parse(localStorage.getItem(`${this.currentPage}_search_prefs`)) || {};
// Apply search options
if (preferences.options) {
this.searchOptionTags.forEach(tag => {
const option = tag.dataset.option;
if (preferences.options[option] !== undefined) {
tag.classList.toggle('active', preferences.options[option]);
}
});
}
// Apply recursive search
if (this.recursiveSearchToggle && preferences.recursive !== undefined) {
this.recursiveSearchToggle.checked = preferences.recursive;
}
} catch (error) {
console.error('Error loading search preferences:', error);
}
}
saveSearchPreferences() {
try {
const options = {};
this.searchOptionTags.forEach(tag => {
options[tag.dataset.option] = tag.classList.contains('active');
});
const preferences = {
options,
recursive: this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false
};
localStorage.setItem(`${this.currentPage}_search_prefs`, JSON.stringify(preferences));
} catch (error) {
console.error('Error saving search preferences:', error);
}
}
getActiveSearchOptions() {
const options = [];
this.searchOptionTags.forEach(tag => {
if (tag.classList.contains('active')) {
options.push(tag.dataset.option);
}
});
return options;
}
performSearch() {
const query = this.searchInput.value.trim();
const options = this.getActiveSearchOptions();
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
// This is a base implementation - each page should override this method
console.log('Performing search:', {
query,
options,
recursive,
page: this.currentPage
});
// Dispatch a custom event that page-specific code can listen for
const searchEvent = new CustomEvent('app:search', {
detail: {
query,
options,
recursive,
page: this.currentPage
}
});
document.dispatchEvent(searchEvent);
}
}

View File

@@ -5,6 +5,7 @@ import { initializeCommonComponents } from './common.js';
import { ImportManager } from './managers/ImportManager.js';
import { RecipeCard } from './components/RecipeCard.js';
import { RecipeModal } from './components/RecipeModal.js';
import { SearchManager } from './managers/SearchManager.js';
class RecipeManager {
constructor() {
@@ -28,6 +29,21 @@ class RecipeManager {
// Load initial set of recipes
this.loadRecipes();
// Initialize search manager with Recipe-specific options
const recipeSearchManager = new SearchManager({
searchCallback: (query, options, recursive) => {
// Recipe-specific search implementation
fetchRecipes({
search: query,
search_options: options,
recursive: recursive
});
}
});
// Set the current page for proper context
document.body.dataset.page = 'recipes';
}
initEventListeners() {

View File

@@ -7,21 +7,21 @@
</a>
</div>
<nav class="main-nav">
<a href="/loras" class="nav-item {% if current_page == 'loras' %}active{% endif %}">
<a href="/loras" class="nav-item" id="lorasNavItem">
<i class="fas fa-layer-group"></i> LoRAs
</a>
<a href="/loras/recipes" class="nav-item {% if current_page == 'recipes' %}active{% endif %}">
<a href="/loras/recipes" class="nav-item" id="recipesNavItem">
<i class="fas fa-book-open"></i> Recipes
</a>
<a href="/checkpoints" class="nav-item {% if current_page == 'checkpoints' %}active{% endif %}">
<a href="/checkpoints" class="nav-item" id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> Checkpoints
</a>
</nav>
<!-- Add search container to header -->
<!-- Context-aware search container -->
<div class="header-search">
<div class="search-container">
<input type="text" id="searchInput" placeholder="Search models..." />
<input type="text" id="searchInput" placeholder="Search..." />
<i class="fas fa-search search-icon"></i>
<button class="search-options-toggle" id="searchOptionsToggle" title="Search Options">
<i class="fas fa-sliders-h"></i>
@@ -55,7 +55,7 @@
</div>
</header>
<!-- Add search options panel -->
<!-- Add search options panel with context-aware options -->
<div id="searchOptionsPanel" class="search-options-panel hidden">
<div class="options-header">
<h3>Search Options</h3>
@@ -67,8 +67,15 @@
<h4>Search In:</h4>
<div class="search-option-tags">
<div class="search-option-tag active" data-option="filename">Filename</div>
{% if request.path == '/loras' or request.path == '/loras/recipes' %}
<div class="search-option-tag active" data-option="tags">Tags</div>
<div class="search-option-tag active" data-option="modelname">Model Name</div>
{% endif %}
<div class="search-option-tag active" data-option="modelname">
{% if request.path == '/loras/recipes' %}Recipe Name{% elif request.path == '/checkpoints' %}Checkpoint Name{% else %}Model Name{% endif %}
</div>
{% if request.path == '/loras/recipes' %}
<div class="search-option-tag active" data-option="loras">Included LoRAs</div>
{% endif %}
</div>
</div>
<div class="options-section">
@@ -108,4 +115,46 @@
Clear All Filters
</button>
</div>
</div>
</div>
<!-- Add this script at the end of the header component -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Get the current path from the URL
const currentPath = window.location.pathname;
// Update search placeholder based on current path
const searchInput = document.getElementById('searchInput');
if (searchInput) {
if (currentPath === '/loras') {
searchInput.placeholder = 'Search LoRAs...';
} else if (currentPath === '/loras/recipes') {
searchInput.placeholder = 'Search recipes...';
} else if (currentPath === '/checkpoints') {
searchInput.placeholder = 'Search checkpoints...';
} else {
searchInput.placeholder = 'Search...';
}
}
// Update active nav item
const lorasNavItem = document.getElementById('lorasNavItem');
const recipesNavItem = document.getElementById('recipesNavItem');
const checkpointsNavItem = document.getElementById('checkpointsNavItem');
const lorasIndicator = document.getElementById('lorasIndicator');
const recipesIndicator = document.getElementById('recipesIndicator');
const checkpointsIndicator = document.getElementById('checkpointsIndicator');
if (currentPath === '/loras') {
lorasNavItem.classList.add('active');
lorasIndicator.style.display = 'block';
} else if (currentPath === '/loras/recipes') {
recipesNavItem.classList.add('active');
recipesIndicator.style.display = 'block';
} else if (currentPath === '/checkpoints') {
checkpointsNavItem.classList.add('active');
checkpointsIndicator.style.display = 'block';
}
});
</script>

View File

@@ -45,7 +45,7 @@
});
</script>
</head>
<body>
<body data-page="loras">
{% include 'components/header.html' %}
<div class="page-content">

View File

@@ -35,7 +35,7 @@
<!-- Resource loading strategy -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
</head>
<body>
<body data-page="recipes">
{% include 'components/header.html' %}
<div class="page-content">
@@ -134,6 +134,16 @@
{% endif %}
<!-- Recipe page specific scripts -->
<script type="module">
import { RecipeFilterManager } from '/loras_static/js/managers/RecipeFilterManager.js';
// Initialize the recipe filter manager
window.recipeFilterManager = new RecipeFilterManager();
// Make it globally available
window.filterManager = window.recipeFilterManager; // For compatibility with existing code
</script>
<script>
// Refresh recipes
function refreshRecipes() {
@@ -141,29 +151,14 @@
window.location.reload();
}
// Placeholder for recipe filter manager
const recipeFilterManager = {
toggleFilterPanel() {
const panel = document.getElementById('filterPanel');
panel.classList.toggle('hidden');
},
closeFilterPanel() {
document.getElementById('filterPanel').classList.add('hidden');
},
clearFilters() {
// Clear filters and reset UI
document.querySelectorAll('.filter-tags .tag.active').forEach(tag => {
tag.classList.remove('active');
});
document.getElementById('activeFiltersCount').style.display = 'none';
// Reapply default view
refreshRecipes();
// Import recipes
function importRecipes() {
// Show import modal
const importModal = document.getElementById('importModal');
if (importModal) {
importModal.classList.remove('hidden');
}
};
}
</script>
</body>
</html>