Merge pull request #3106 from joenunezb/optimize/improve-search perf(search): improve fuzzy character search performance by ~13x (4.5s → 350ms)

c3c16ea0d61802f610f7164a7cf004d9ecc49e8a

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
3 files changed, +136 -93Ignore whitespace
public/script.js+4 -3
@@ -1621,7 +1621,7 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
16211621 subEntities = filterByTagState(entities, { subForEntity: entity });
16221622 if (doFilter) {
16231623 // sub entities filter "hacked" because folder filter should not be applied there, so even in "only folders" mode characters show up
16241624 subEntities = entitiesFilter.applyFilters(subEntities, { clearScoreCache: false, tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
16251625 }
16261626 if (doSort) {
16271627 sortEntitiesList(subEntities);
@@ -1634,11 +1634,11 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
16341634 // Second run filters, hiding whatever should be filtered later
16351635 if (doFilter) {
16361636 const beforeFinalEntities = filterByTagState(entities, { globalDisplayFilters: true });
16371637 entities = entitiesFilter.applyFilters(beforeFinalEntities, { clearFuzzySearchCaches: false });
16381638
16391639 // Magic for folder filter. If that one is enabled, and no folders are display anymore, we remove that filter to actually show the characters.
16401640 if (isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED) && entities.filter(x => x.type == 'tag').length == 0) {
16411641 entities = entitiesFilter.applyFilters(beforeFinalEntities, { tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
16421642 }
16431643 }
16441644
@@ -1654,6 +1654,7 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
16541654 if (doSort) {
16551655 sortEntitiesList(entities);
16561656 }
1657+ entitiesFilter.clearFuzzySearchCaches();
16571658 return entities;
16581659}
16591660
public/scripts/filters.js+45 -6
@@ -56,6 +56,19 @@ export function isFilterState(a, b) {
5656}
5757
5858/**
59+ * The fuzzy search categories
60+ * @type {{ characters: string, worldInfo: string, personas: string, tags: string, groups: string }}
61+ */
62+export const fuzzySearchCategories = Object.freeze({
63+ characters: 'characters',
64+ worldInfo: 'worldInfo',
65+ personas: 'personas',
66+ tags: 'tags',
67+ groups: 'groups',
68+});
69+
70+
71+/**
5972 * Helper class for filtering data.
6073 * @example
6174 * const filterHelper = new FilterHelper(() => console.log('data changed'));
@@ -73,12 +86,25 @@ export class FilterHelper {
7386 scoreCache;
7487
7588 /**
89+ * Cache for fuzzy search results per category.
90+ * @type {Object.<string, { resultMap: Map<string, any> }>}
91+ */
92+ fuzzySearchCaches;
93+
94+ /**
7695 * Creates a new FilterHelper
7796 * @param {Function} onDataChanged Callback to trigger when the filter data changes
7897 */
7998 constructor(onDataChanged) {
8099 this.onDataChanged = onDataChanged;
81100 this.scoreCache = new Map();
101+ this.fuzzySearchCaches = {
102+ [fuzzySearchCategories.characters]: { resultMap: new Map() },
103+ [fuzzySearchCategories.worldInfo]: { resultMap: new Map() },
104+ [fuzzySearchCategories.personas]: { resultMap: new Map() },
105+ [fuzzySearchCategories.tags]: { resultMap: new Map() },
106+ [fuzzySearchCategories.groups]: { resultMap: new Map() },
107+ };
82108 }
83109
84110 /**
@@ -151,7 +177,7 @@ export class FilterHelper {
151177 return data;
152178 }
153179
154180 const fuzzySearchResults = fuzzySearchWorldInfo(data, term, this.fuzzySearchCaches);
155181 this.cacheScores(FILTER_TYPES.WORLD_INFO_SEARCH, new Map(fuzzySearchResults.map(i => [i.item?.uid, i.score])));
156182
157183 const filteredData = data.filter(entity => fuzzySearchResults.find(x => x.item === entity));
@@ -170,7 +196,7 @@ export class FilterHelper {
170196 return data;
171197 }
172198
173199 const fuzzySearchResults = fuzzySearchPersonas(data, term, this.fuzzySearchCaches);
174200 this.cacheScores(FILTER_TYPES.PERSONA_SEARCH, new Map(fuzzySearchResults.map(i => [i.item.key, i.score])));
175201
176202 const filteredData = data.filter(name => fuzzySearchResults.find(x => x.item.key === name));
@@ -289,9 +315,9 @@ export class FilterHelper {
289315
290316 // Save fuzzy search results and scores if enabled
291317 if (power_user.fuzzy_search) {
292318 const fuzzySearchCharactersResults = fuzzySearchCharacters(searchValue, this.fuzzySearchCaches);
293319 const fuzzySearchGroupsResults = fuzzySearchGroups(searchValue, this.fuzzySearchCaches);
294320 const fuzzySearchTagsResult = fuzzySearchTags(searchValue, this.fuzzySearchCaches);
295321 this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchCharactersResults.map(i => [`character.${i.refIndex}`, i.score])));
296322 this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchGroupsResults.map(i => [`group.${i.item.id}`, i.score])));
297323 this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchTagsResult.map(i => [`tag.${i.item.id}`, i.score])));
@@ -343,11 +369,14 @@ export class FilterHelper {
343369 * @param {object} options - Optional call parameters
344370 * @param {boolean} [options.clearScoreCache=true] - Whether the score cache should be cleared.
345371 * @param {Object.<FilterType, any>} [options.tempOverrides={}] - Temporarily override specific filters for this filter application
372+ * @param {boolean} [options.clearFuzzySearchCaches=true] - Whether the fuzzy search caches should be cleared.
346373 * @returns {any[]} The filtered data.
347374 */
348375 applyFilters(data, { clearScoreCache = true, tempOverrides = {}, clearFuzzySearchCaches = true } = {}) {
349376 if (clearScoreCache) this.clearScoreCache();
350377
378+ if (clearFuzzySearchCaches) this.clearFuzzySearchCaches();
379+
351380 // Save original filter states
352381 const originalStates = {};
353382 for (const key in tempOverrides) {
@@ -411,4 +440,14 @@ export class FilterHelper {
411440 this.scoreCache = new Map();
412441 }
413442 }
443+
444+ /**
445+ * Clears fuzzy search caches
446+ */
447+ clearFuzzySearchCaches() {
448+ for (const cache of Object.values(this.fuzzySearchCaches)) {
449+ cache.resultMap.clear();
450+ }
451+ console.log('All fuzzy search caches cleared');
452+ }
414453}
public/scripts/power-user.js+87 -84
@@ -53,6 +53,7 @@ import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandE
5353import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
5454import { POPUP_TYPE, callGenericPopup } from './popup.js';
5555import { loadSystemPrompts } from './sysprompt.js';
56+import { fuzzySearchCategories } from './filters.js';
5657
5758export {
5859 loadPowerUserSettings,
@@ -1829,27 +1830,28 @@ async function loadContextSettings() {
18291830 });
18301831}
18311832
1833+
18321834/**
18331835 * FuzzyCommon searchfunction charactersto byperform afuzzy search termwith optional caching
1836+ * @param {string} type - Type of search from fuzzySearchCategories
1837+ * @param {any[]} data - Data array to search in
1838+ * @param {Array<{name: string, weight: number, getFn?: Function}>} keys - Fuse.js keys configuration
18341839 * @param {string} searchValue - The search term
1840+ * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
18351841 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
18361842 */
1837-export function fuzzySearchCharacters(searchValue) {
1843+function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
1844+ // Check cache if provided
1845+ if (fuzzySearchCaches) {
1846+ const cache = fuzzySearchCaches[type];
1847+ if (cache?.resultMap.has(searchValue)) {
1848+ return cache.resultMap.get(searchValue);
1849+ }
1850+ }
1851+
18381852 // @ts-ignore
18391853 const fuse = new Fuse(charactersdata, {
18401854 keys: [keys,
1841- { name: 'data.name', weight: 20 },
1842- { name: '#tags', weight: 10, getFn: (character) => getTagsList(character.avatar).map(x => x.name).join('||') },
1843- { name: 'data.description', weight: 3 },
1844- { name: 'data.mes_example', weight: 3 },
1845- { name: 'data.scenario', weight: 2 },
1846- { name: 'data.personality', weight: 2 },
1847- { name: 'data.first_mes', weight: 2 },
1848- { name: 'data.creator_notes', weight: 2 },
1849- { name: 'data.creator', weight: 1 },
1850- { name: 'data.tags', weight: 1 },
1851- { name: 'data.alternate_greetings', weight: 1 },
1852- ],
18531855 includeScore: true,
18541856 ignoreLocation: true,
18551857 useExtendedSearch: true,
@@ -1857,109 +1859,110 @@ export function fuzzySearchCharacters(searchValue) {
18571859 });
18581860
18591861 const results = fuse.search(searchValue);
1860- console.debug('Characters fuzzy search results for ' + searchValue, results);
1862+
1863+ // Store in cache if provided
1864+ if (fuzzySearchCaches) {
1865+ fuzzySearchCaches[type].resultMap.set(searchValue, results);
1866+ }
18611867 return results;
18621868}
18631869
18641870/**
1871+ * Fuzzy search characters by a search term
1872+ * @param {string} searchValue - The search term
1873+ * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
1874+ * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
1875+ */
1876+export function fuzzySearchCharacters(searchValue, fuzzySearchCaches = null) {
1877+ const keys = [
1878+ { name: 'data.name', weight: 20 },
1879+ { name: '#tags', weight: 10, getFn: (character) => getTagsList(character.avatar).map(x => x.name).join('||') },
1880+ { name: 'data.description', weight: 3 },
1881+ { name: 'data.mes_example', weight: 3 },
1882+ { name: 'data.scenario', weight: 2 },
1883+ { name: 'data.personality', weight: 2 },
1884+ { name: 'data.first_mes', weight: 2 },
1885+ { name: 'data.creator_notes', weight: 2 },
1886+ { name: 'data.creator', weight: 1 },
1887+ { name: 'data.tags', weight: 1 },
1888+ { name: 'data.alternate_greetings', weight: 1 },
1889+ ];
1890+
1891+ return performFuzzySearch(fuzzySearchCategories.characters, characters, keys, searchValue, fuzzySearchCaches);
1892+}
1893+
1894+/**
18651895 * Fuzzy search world info entries by a search term
18661896 * @param {*[]} data - WI items data array
18671897 * @param {string} searchValue - The search term
1898+ * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
18681899 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
18691900 */
18701901export function fuzzySearchWorldInfo(data, searchValue, fuzzySearchCaches = null) {
1871- // @ts-ignore
1902+ const keys = [
1872- const fuse = new Fuse(data, {
1903+ { name: 'key', weight: 20 },
1873- keys: [
1904+ { name: 'group', weight: 15 },
18741905 { name: 'keycomment', weight: 2010 },
18751906 { name: 'groupkeysecondary', weight: 1510 },
18761907 { name: 'commentcontent', weight: 103 },
18771908 { name: 'keysecondaryuid', weight: 101 },
18781909 { name: 'contentautomationId', weight: 31 },
1879- { name: 'uid', weight: 1 },
1910+ ];
1880- { name: 'automationId', weight: 1 },
1881- ],
1882- includeScore: true,
1883- ignoreLocation: true,
1884- useExtendedSearch: true,
1885- threshold: 0.2,
1886- });
18871911
1888- const results = fuse.search(searchValue);
1912+ return performFuzzySearch(fuzzySearchCategories.worldInfo, data, keys, searchValue, fuzzySearchCaches);
1889- console.debug('World Info fuzzy search results for ' + searchValue, results);
1890- return results;
18911913}
18921914
18931915/**
18941916 * Fuzzy search persona entries by a search term
18951917 * @param {*[]} data - persona data array
18961918 * @param {string} searchValue - The search term
1919+ * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
18971920 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
18981921 */
18991922export function fuzzySearchPersonas(data, searchValue, fuzzySearchCaches = null) {
1900- data = data.map(x => ({ key: x, name: power_user.personas[x] ?? '', description: power_user.persona_descriptions[x]?.description ?? '' }));
1923+ const mappedData = data.map(x => ({
1901- // @ts-ignore
1924+ key: x,
1902- const fuse = new Fuse(data, {
1925+ name: power_user.personas[x] ?? '',
1903- keys: [
1926+ description: power_user.persona_descriptions[x]?.description ?? ''
1904- { name: 'name', weight: 20 },
1927+ }));
1905- { name: 'description', weight: 3 },
1906- ],
1907- includeScore: true,
1908- ignoreLocation: true,
1909- useExtendedSearch: true,
1910- threshold: 0.2,
1911- });
19121928
19131929 const resultskeys = fuse.search(searchValue);[
1914- console.debug('Personas fuzzy search results for ' + searchValue, results);
1930+ { name: 'name', weight: 20 },
1915- return results;
1931+ { name: 'description', weight: 3 },
1932+ ];
1933+
1934+ return performFuzzySearch(fuzzySearchCategories.personas, mappedData, keys, searchValue, fuzzySearchCaches);
19161935}
19171936
19181937/**
19191938 * Fuzzy search tags by a search term
19201939 * @param {string} searchValue - The search term
1940+ * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
19211941 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
19221942 */
19231943export function fuzzySearchTags(searchValue, fuzzySearchCaches = null) {
1924- // @ts-ignore
1944+ const keys = [
1925- const fuse = new Fuse(tags, {
1945+ { name: 'name', weight: 1 },
1926- keys: [
1946+ ];
1927- { name: 'name', weight: 1 },
1928- ],
1929- includeScore: true,
1930- ignoreLocation: true,
1931- useExtendedSearch: true,
1932- threshold: 0.2,
1933- });
19341947
1935- const results = fuse.search(searchValue);
1948+ return performFuzzySearch(fuzzySearchCategories.tags, tags, keys, searchValue, fuzzySearchCaches);
1936- console.debug('Tags fuzzy search results for ' + searchValue, results);
1937- return results;
19381949}
19391950
19401951/**
19411952 * Fuzzy search groups by a search term
19421953 * @param {string} searchValue - The search term
1954+ * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
19431955 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
19441956 */
19451957export function fuzzySearchGroups(searchValue, fuzzySearchCaches = null) {
1946- // @ts-ignore
1958+ const keys = [
1947- const fuse = new Fuse(groups, {
1959+ { name: 'name', weight: 20 },
1948- keys: [
1960+ { name: 'members', weight: 15 },
1949- { name: 'name', weight: 20 },
1961+ { name: '#tags', weight: 10, getFn: (group) => getTagsList(group.id).map(x => x.name).join('||') },
19501962 { name: 'membersid', weight: 151 },
1951- { name: '#tags', weight: 10, getFn: (group) => getTagsList(group.id).map(x => x.name).join('||') },
1963+ ];
1952- { name: 'id', weight: 1 },
1953- ],
1954- includeScore: true,
1955- ignoreLocation: true,
1956- useExtendedSearch: true,
1957- threshold: 0.2,
1958- });
19591964
1960- const results = fuse.search(searchValue);
1965+ return performFuzzySearch(fuzzySearchCategories.groups, groups, keys, searchValue, fuzzySearchCaches);
1961- console.debug('Groups fuzzy search results for ' + searchValue, results);
1962- return results;
19631966}
19641967
19651968/**