feat: implement alphabet filtering feature with letter counts and UI components v1

This commit is contained in:
Will Miao
2025-05-01 20:07:12 +08:00
parent 9dbcc105e7
commit d1fd5b7f27
11 changed files with 509 additions and 2 deletions

View File

@@ -69,6 +69,9 @@ class ApiRoutes:
# Add the new trigger words route # Add the new trigger words route
app.router.add_post('/loramanager/get_trigger_words', routes.get_trigger_words) app.router.add_post('/loramanager/get_trigger_words', routes.get_trigger_words)
# Add new endpoint for letter counts
app.router.add_get('/api/loras/letter-counts', routes.get_letter_counts)
# Add update check routes # Add update check routes
UpdateRoutes.setup_routes(app) UpdateRoutes.setup_routes(app)
@@ -126,6 +129,9 @@ class ApiRoutes:
tags = request.query.get('tags', None) tags = request.query.get('tags', None)
favorites_only = request.query.get('favorites_only', 'false').lower() == 'true' # New parameter favorites_only = request.query.get('favorites_only', 'false').lower() == 'true' # New parameter
# New parameter for alphabet filtering
first_letter = request.query.get('first_letter', None)
# New parameters for recipe filtering # New parameters for recipe filtering
lora_hash = request.query.get('lora_hash', None) lora_hash = request.query.get('lora_hash', None)
lora_hashes = request.query.get('lora_hashes', None) lora_hashes = request.query.get('lora_hashes', None)
@@ -156,7 +162,8 @@ class ApiRoutes:
tags=filters.get('tags', None), tags=filters.get('tags', None),
search_options=search_options, search_options=search_options,
hash_filters=hash_filters, hash_filters=hash_filters,
favorites_only=favorites_only # Pass favorites_only parameter favorites_only=favorites_only, # Pass favorites_only parameter
first_letter=first_letter # Pass the new first_letter parameter
) )
# Get all available folders from cache # Get all available folders from cache
@@ -1050,3 +1057,23 @@ class ApiRoutes:
"success": False, "success": False,
"error": str(e) "error": str(e)
}, status=500) }, status=500)
async def get_letter_counts(self, request: web.Request) -> web.Response:
"""Get count of loras for each letter of the alphabet"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
# Get letter counts
letter_counts = await self.scanner.get_letter_counts()
return web.json_response({
'success': True,
'letter_counts': letter_counts
})
except Exception as e:
logger.error(f"Error getting letter counts: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -4,6 +4,7 @@ import logging
import asyncio import asyncio
import shutil import shutil
import time import time
import re
from typing import List, Dict, Optional, Set from typing import List, Dict, Optional, Set
from ..utils.models import LoraMetadata from ..utils.models import LoraMetadata
@@ -123,7 +124,7 @@ class LoraScanner(ModelScanner):
folder: str = None, search: str = None, fuzzy_search: bool = False, folder: str = None, search: str = None, fuzzy_search: bool = False,
base_models: list = None, tags: list = None, base_models: list = None, tags: list = None,
search_options: dict = None, hash_filters: dict = None, search_options: dict = None, hash_filters: dict = None,
favorites_only: bool = False) -> Dict: favorites_only: bool = False, first_letter: str = None) -> Dict:
"""Get paginated and filtered lora data """Get paginated and filtered lora data
Args: Args:
@@ -138,6 +139,7 @@ class LoraScanner(ModelScanner):
search_options: Dictionary with search options (filename, modelname, tags, recursive) search_options: Dictionary with search options (filename, modelname, tags, recursive)
hash_filters: Dictionary with hash filtering options (single_hash or multiple_hashes) hash_filters: Dictionary with hash filtering options (single_hash or multiple_hashes)
favorites_only: Filter for favorite models only favorites_only: Filter for favorite models only
first_letter: Filter by first letter of model name
""" """
cache = await self.get_cached_data() cache = await self.get_cached_data()
@@ -202,6 +204,10 @@ class LoraScanner(ModelScanner):
lora for lora in filtered_data lora for lora in filtered_data
if lora.get('favorite', False) is True if lora.get('favorite', False) is True
] ]
# Apply first letter filtering
if first_letter:
filtered_data = self._filter_by_first_letter(filtered_data, first_letter)
# Apply folder filtering # Apply folder filtering
if folder is not None: if folder is not None:
@@ -273,6 +279,101 @@ class LoraScanner(ModelScanner):
return result return result
def _filter_by_first_letter(self, data, letter):
"""Filter data by first letter of model name
Special handling:
- '#': Numbers (0-9)
- '@': Special characters (not alphanumeric)
- '': CJK characters
"""
filtered_data = []
for lora in data:
model_name = lora.get('model_name', '')
if not model_name:
continue
first_char = model_name[0].upper()
if letter == '#' and first_char.isdigit():
filtered_data.append(lora)
elif letter == '@' and not first_char.isalnum():
# Special characters (not alphanumeric)
filtered_data.append(lora)
elif letter == '' and self._is_cjk_character(first_char):
# CJK characters
filtered_data.append(lora)
elif letter.upper() == first_char:
# Regular alphabet matching
filtered_data.append(lora)
return filtered_data
def _is_cjk_character(self, char):
"""Check if character is a CJK character"""
# Define Unicode ranges for CJK characters
cjk_ranges = [
(0x4E00, 0x9FFF), # CJK Unified Ideographs
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
(0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E
(0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F
(0x30000, 0x3134F), # CJK Unified Ideographs Extension G
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
(0x3300, 0x33FF), # CJK Compatibility
(0x3200, 0x32FF), # Enclosed CJK Letters and Months
(0x3100, 0x312F), # Bopomofo
(0x31A0, 0x31BF), # Bopomofo Extended
(0x3040, 0x309F), # Hiragana
(0x30A0, 0x30FF), # Katakana
(0x31F0, 0x31FF), # Katakana Phonetic Extensions
(0xAC00, 0xD7AF), # Hangul Syllables
(0x1100, 0x11FF), # Hangul Jamo
(0xA960, 0xA97F), # Hangul Jamo Extended-A
(0xD7B0, 0xD7FF), # Hangul Jamo Extended-B
]
code_point = ord(char)
return any(start <= code_point <= end for start, end in cjk_ranges)
async def get_letter_counts(self):
"""Get count of models for each letter of the alphabet"""
cache = await self.get_cached_data()
data = cache.sorted_by_name
# Define letter categories
letters = {
'#': 0, # Numbers
'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 0, 'F': 0, 'G': 0, 'H': 0,
'I': 0, 'J': 0, 'K': 0, 'L': 0, 'M': 0, 'N': 0, 'O': 0, 'P': 0,
'Q': 0, 'R': 0, 'S': 0, 'T': 0, 'U': 0, 'V': 0, 'W': 0, 'X': 0,
'Y': 0, 'Z': 0,
'@': 0, # Special characters
'': 0 # CJK characters
}
# Count models for each letter
for lora in data:
model_name = lora.get('model_name', '')
if not model_name:
continue
first_char = model_name[0].upper()
if first_char.isdigit():
letters['#'] += 1
elif first_char in letters:
letters[first_char] += 1
elif self._is_cjk_character(first_char):
letters[''] += 1
elif not first_char.isalnum():
letters['@'] += 1
return letters
async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict: async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict:
"""Update file paths in metadata file""" """Update file paths in metadata file"""
try: try:

View File

@@ -0,0 +1,89 @@
/* Alphabet Bar Component */
.alphabet-bar {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 4px;
margin: 8px 0;
display: flex;
gap: 4px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
.letter-chip {
padding: 4px 6px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
cursor: pointer;
min-width: 20px;
text-align: center;
font-size: 0.85em;
transition: all 0.2s ease;
border: 1px solid var(--border-color);
}
.letter-chip:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.letter-chip.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
.letter-chip.disabled {
opacity: 0.5;
pointer-events: none;
cursor: default;
}
.letter-chip .count {
font-size: 0.75em;
margin-left: 2px;
opacity: 0.7;
}
.alphabet-bar-section {
margin: 6px 0;
padding: 0 2px;
position: relative;
}
.alphabet-bar-title {
font-size: 0.75em;
color: var(--text-color);
opacity: 0.7;
margin-right: 8px;
white-space: nowrap;
}
@media (max-width: 768px) {
.alphabet-bar {
padding: 3px;
gap: 3px;
}
.letter-chip {
padding: 3px 5px;
min-width: 16px;
font-size: 0.75em;
}
}
/* Keyframe animations for the active letter */
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.letter-chip.active {
animation: pulse 1s ease-in-out 1;
}

View File

@@ -21,6 +21,7 @@
@import 'components/filter-indicator.css'; @import 'components/filter-indicator.css';
@import 'components/initialization.css'; @import 'components/initialization.css';
@import 'components/progress-panel.css'; @import 'components/progress-panel.css';
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
.initialization-notice { .initialization-notice {
display: flex; display: flex;

View File

@@ -49,6 +49,11 @@ export async function loadMoreModels(options = {}) {
if (pageState.showFavoritesOnly) { if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true'); params.append('favorites_only', 'true');
} }
// Add active letter filter if set
if (pageState.activeLetterFilter) {
params.append('first_letter', pageState.activeLetterFilter);
}
// Add search parameters if there's a search term // Add search parameters if there's a search term
if (pageState.filters?.search) { if (pageState.filters?.search) {

View File

@@ -0,0 +1,222 @@
// AlphabetBar.js - Component for alphabet filtering
import { getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js';
import { resetAndReload } from '../../api/loraApi.js';
/**
* AlphabetBar class - Handles the alphabet filtering UI and interactions
*/
export class AlphabetBar {
constructor(pageType = 'loras') {
// Store the page type
this.pageType = pageType;
// Get the current page state
this.pageState = getCurrentPageState();
// Initialize letter counts
this.letterCounts = {};
// Initialize the component
this.initializeComponent();
}
/**
* Initialize the alphabet bar component
*/
async initializeComponent() {
// Get letter counts from API
await this.fetchLetterCounts();
// Initialize event listeners
this.initEventListeners();
// Restore the active letter filter from storage if available
this.restoreActiveLetterFilter();
}
/**
* Fetch letter counts from the API
*/
async fetchLetterCounts() {
try {
const response = await fetch('/api/loras/letter-counts');
if (!response.ok) {
throw new Error(`Failed to fetch letter counts: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.letter_counts) {
this.letterCounts = data.letter_counts;
// Update the count display in the UI
this.updateLetterCountsDisplay();
}
} catch (error) {
console.error('Error fetching letter counts:', error);
}
}
/**
* Update the letter counts display in the UI
*/
updateLetterCountsDisplay() {
const letterChips = document.querySelectorAll('.letter-chip');
letterChips.forEach(chip => {
const letter = chip.dataset.letter;
const countSpan = chip.querySelector('.count');
const count = this.letterCounts[letter] || 0;
// Update the count display
if (countSpan) {
if (count > 0) {
countSpan.textContent = ` (${count})`;
chip.title = `${letter}: ${count} LoRAs`;
chip.classList.remove('disabled');
} else {
countSpan.textContent = '';
chip.title = `${letter}: No LoRAs`;
chip.classList.add('disabled');
}
}
});
}
/**
* Initialize event listeners for the alphabet bar
*/
initEventListeners() {
const alphabetBar = document.querySelector('.alphabet-bar');
if (alphabetBar) {
// Use event delegation for letter chips
alphabetBar.addEventListener('click', (e) => {
const letterChip = e.target.closest('.letter-chip');
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
}
});
// Add keyboard shortcut listeners
document.addEventListener('keydown', (e) => {
// Alt + letter shortcuts
if (e.altKey && !e.ctrlKey && !e.metaKey) {
const key = e.key.toUpperCase();
// Check if it's a letter A-Z
if (/^[A-Z]$/.test(key)) {
const letterChip = document.querySelector(`.letter-chip[data-letter="${key}"]`);
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
e.preventDefault();
}
}
// Special cases for non-letter filters
else if (e.key === '0' || e.key === ')') {
// Alt+0 for numbers (#)
const letterChip = document.querySelector('.letter-chip[data-letter="#"]');
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
e.preventDefault();
}
} else if (e.key === '2' || e.key === '@') {
// Alt+@ for special characters
const letterChip = document.querySelector('.letter-chip[data-letter="@"]');
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
e.preventDefault();
}
} else if (e.key === 'c' || e.key === 'C') {
// Alt+C for CJK characters
const letterChip = document.querySelector('.letter-chip[data-letter="漢"]');
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
e.preventDefault();
}
}
}
});
}
}
/**
* Handle letter chip click
* @param {HTMLElement} letterChip - The letter chip that was clicked
*/
handleLetterClick(letterChip) {
const letter = letterChip.dataset.letter;
const wasActive = letterChip.classList.contains('active');
// Remove active class from all letter chips
document.querySelectorAll('.letter-chip').forEach(chip => {
chip.classList.remove('active');
});
if (!wasActive) {
// Set the new active letter
letterChip.classList.add('active');
this.pageState.activeLetterFilter = letter;
// Save to storage
setStorageItem(`${this.pageType}_activeLetterFilter`, letter);
} else {
// Clear the active letter filter
this.pageState.activeLetterFilter = null;
// Remove from storage
setStorageItem(`${this.pageType}_activeLetterFilter`, null);
}
// Trigger a reload with the new filter
resetAndReload(true);
}
/**
* Restore the active letter filter from storage
*/
restoreActiveLetterFilter() {
const activeLetterFilter = getStorageItem(`${this.pageType}_activeLetterFilter`);
if (activeLetterFilter) {
const letterChip = document.querySelector(`.letter-chip[data-letter="${activeLetterFilter}"]`);
if (letterChip && !letterChip.classList.contains('disabled')) {
letterChip.classList.add('active');
this.pageState.activeLetterFilter = activeLetterFilter;
}
}
}
/**
* Clear the active letter filter
*/
clearActiveLetterFilter() {
// Remove active class from all letter chips
document.querySelectorAll('.letter-chip').forEach(chip => {
chip.classList.remove('active');
});
// Clear the active letter filter
this.pageState.activeLetterFilter = null;
// Remove from storage
setStorageItem(`${this.pageType}_activeLetterFilter`, null);
}
/**
* Update letter counts with new data
* @param {Object} newCounts - New letter count data
*/
updateCounts(newCounts) {
this.letterCounts = { ...newCounts };
this.updateLetterCountsDisplay();
}
}

View File

@@ -0,0 +1,14 @@
// Alphabet component index file
import { AlphabetBar } from './AlphabetBar.js';
// Export the class
export { AlphabetBar };
/**
* Factory function to create the appropriate alphabet bar
* @param {string} pageType - The type of page ('loras' or 'checkpoints')
* @returns {AlphabetBar} - The alphabet bar instance
*/
export function createAlphabetBar(pageType) {
return new AlphabetBar(pageType);
}

View File

@@ -2,6 +2,7 @@
import { PageControls } from './PageControls.js'; import { PageControls } from './PageControls.js';
import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js'; import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js';
import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { createAlphabetBar } from '../alphabet/index.js';
/** /**
* LorasControls class - Extends PageControls for LoRA-specific functionality * LorasControls class - Extends PageControls for LoRA-specific functionality
@@ -16,6 +17,9 @@ export class LorasControls extends PageControls {
// Check for custom filters (e.g., from recipe navigation) // Check for custom filters (e.g., from recipe navigation)
this.checkCustomFilters(); this.checkCustomFilters();
// Initialize alphabet bar component
this.initAlphabetBar();
} }
/** /**
@@ -142,4 +146,27 @@ export class LorasControls extends PageControls {
_truncateText(text, maxLength) { _truncateText(text, maxLength) {
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text; return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
} }
/**
* Initialize the alphabet bar component
*/
initAlphabetBar() {
// Create the alphabet bar component
this.alphabetBar = createAlphabetBar('loras');
// Expose the alphabet bar to the global scope for debugging
window.alphabetBar = this.alphabetBar;
}
/**
* Override resetAndReload to update letter counts
*/
async resetAndReload(updateFolders = false) {
await super.resetAndReload(updateFolders);
// Update letter counts after reload if alphabet bar exists
if (this.alphabetBar) {
this.alphabetBar.fetchLetterCounts();
}
}
} }

View File

@@ -27,6 +27,7 @@ export const state = {
hasMore: true, hasMore: true,
sortBy: 'name', sortBy: 'name',
activeFolder: null, activeFolder: null,
activeLetterFilter: null, // New property for letter filtering
previewVersions: loraPreviewVersions, previewVersions: loraPreviewVersions,
searchManager: null, searchManager: null,
searchOptions: { searchOptions: {

View File

@@ -0,0 +1,19 @@
<div class="alphabet-bar-section">
<div class="alphabet-bar">
<span class="alphabet-bar-title">Filter by:</span>
<div class="letter-chip" data-letter="#" title="Numbers">
#<span class="count"></span>
</div>
{% for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" %}
<div class="letter-chip" data-letter="{{ letter }}" title="{{ letter }}">
{{ letter }}<span class="count"></span>
</div>
{% endfor %}
<div class="letter-chip" data-letter="@" title="Special characters">
@<span class="count"></span>
</div>
<div class="letter-chip" data-letter="漢" title="CJK characters">
<span class="count"></span>
</div>
</div>
</div>

View File

@@ -19,6 +19,7 @@
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
{% include 'components/alphabet_bar.html' %}
<!-- Lora卡片容器 --> <!-- Lora卡片容器 -->
<div class="card-grid" id="loraGrid"> <div class="card-grid" id="loraGrid">
<!-- Cards will be dynamically inserted here --> <!-- Cards will be dynamically inserted here -->