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 } = {}) {
1621 subEntities = filterByTagState(entities, { subForEntity: entity });1621 subEntities = filterByTagState(entities, { subForEntity: entity });
1622 if (doFilter) {1622 if (doFilter) {
1623 // sub entities filter "hacked" because folder filter should not be applied there, so even in "only folders" mode characters show up1623 // sub entities filter "hacked" because folder filter should not be applied there, so even in "only folders" mode characters show up
1624 subEntities = entitiesFilter.applyFilters(subEntities, { clearScoreCache: false, tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED } });1624 subEntities = entitiesFilter.applyFilters(subEntities, { clearScoreCache: false, tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
1625 }1625 }
1626 if (doSort) {1626 if (doSort) {
1627 sortEntitiesList(subEntities);1627 sortEntitiesList(subEntities);
@@ -1634,11 +1634,11 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
1634 // Second run filters, hiding whatever should be filtered later1634 // Second run filters, hiding whatever should be filtered later
1635 if (doFilter) {1635 if (doFilter) {
1636 const beforeFinalEntities = filterByTagState(entities, { globalDisplayFilters: true });1636 const beforeFinalEntities = filterByTagState(entities, { globalDisplayFilters: true });
1637 entities = entitiesFilter.applyFilters(beforeFinalEntities);1637 entities = entitiesFilter.applyFilters(beforeFinalEntities, { clearFuzzySearchCaches: false });
16381638
1639 // Magic for folder filter. If that one is enabled, and no folders are display anymore, we remove that filter to actually show the characters.1639 // Magic for folder filter. If that one is enabled, and no folders are display anymore, we remove that filter to actually show the characters.
1640 if (isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED) && entities.filter(x => x.type == 'tag').length == 0) {1640 if (isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED) && entities.filter(x => x.type == 'tag').length == 0) {
1641 entities = entitiesFilter.applyFilters(beforeFinalEntities, { tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED } });1641 entities = entitiesFilter.applyFilters(beforeFinalEntities, { tempOverrides: { [FILTER_TYPES.FOLDER]: FILTER_STATES.UNDEFINED }, clearFuzzySearchCaches: false });
1642 }1642 }
1643 }1643 }
16441644
@@ -1654,6 +1654,7 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
1654 if (doSort) {1654 if (doSort) {
1655 sortEntitiesList(entities);1655 sortEntitiesList(entities);
1656 }1656 }
1657 entitiesFilter.clearFuzzySearchCaches();
1657 return entities;1658 return entities;
1658}1659}
16591660
public/scripts/filters.js+45 -6
@@ -56,6 +56,19 @@ export function isFilterState(a, b) {
56}56}
5757
58/**58/**
59 * The fuzzy search categories
60 * @type {{ characters: string, worldInfo: string, personas: string, tags: string, groups: string }}
61 */
62export const fuzzySearchCategories = Object.freeze({
63 characters: 'characters',
64 worldInfo: 'worldInfo',
65 personas: 'personas',
66 tags: 'tags',
67 groups: 'groups',
68});
69
70
71/**
59 * Helper class for filtering data.72 * Helper class for filtering data.
60 * @example73 * @example
61 * const filterHelper = new FilterHelper(() => console.log('data changed'));74 * const filterHelper = new FilterHelper(() => console.log('data changed'));
@@ -73,12 +86,25 @@ export class FilterHelper {
73 scoreCache;86 scoreCache;
7487
75 /**88 /**
89 * Cache for fuzzy search results per category.
90 * @type {Object.<string, { resultMap: Map<string, any> }>}
91 */
92 fuzzySearchCaches;
93
94 /**
76 * Creates a new FilterHelper95 * Creates a new FilterHelper
77 * @param {Function} onDataChanged Callback to trigger when the filter data changes96 * @param {Function} onDataChanged Callback to trigger when the filter data changes
78 */97 */
79 constructor(onDataChanged) {98 constructor(onDataChanged) {
80 this.onDataChanged = onDataChanged;99 this.onDataChanged = onDataChanged;
81 this.scoreCache = new Map();100 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 };
82 }108 }
83109
84 /**110 /**
@@ -151,7 +177,7 @@ export class FilterHelper {
151 return data;177 return data;
152 }178 }
153179
154 const fuzzySearchResults = fuzzySearchWorldInfo(data, term);180 const fuzzySearchResults = fuzzySearchWorldInfo(data, term, this.fuzzySearchCaches);
155 this.cacheScores(FILTER_TYPES.WORLD_INFO_SEARCH, new Map(fuzzySearchResults.map(i => [i.item?.uid, i.score])));181 this.cacheScores(FILTER_TYPES.WORLD_INFO_SEARCH, new Map(fuzzySearchResults.map(i => [i.item?.uid, i.score])));
156182
157 const filteredData = data.filter(entity => fuzzySearchResults.find(x => x.item === entity));183 const filteredData = data.filter(entity => fuzzySearchResults.find(x => x.item === entity));
@@ -170,7 +196,7 @@ export class FilterHelper {
170 return data;196 return data;
171 }197 }
172198
173 const fuzzySearchResults = fuzzySearchPersonas(data, term);199 const fuzzySearchResults = fuzzySearchPersonas(data, term, this.fuzzySearchCaches);
174 this.cacheScores(FILTER_TYPES.PERSONA_SEARCH, new Map(fuzzySearchResults.map(i => [i.item.key, i.score])));200 this.cacheScores(FILTER_TYPES.PERSONA_SEARCH, new Map(fuzzySearchResults.map(i => [i.item.key, i.score])));
175201
176 const filteredData = data.filter(name => fuzzySearchResults.find(x => x.item.key === name));202 const filteredData = data.filter(name => fuzzySearchResults.find(x => x.item.key === name));
@@ -289,9 +315,9 @@ export class FilterHelper {
289315
290 // Save fuzzy search results and scores if enabled316 // Save fuzzy search results and scores if enabled
291 if (power_user.fuzzy_search) {317 if (power_user.fuzzy_search) {
292 const fuzzySearchCharactersResults = fuzzySearchCharacters(searchValue);318 const fuzzySearchCharactersResults = fuzzySearchCharacters(searchValue, this.fuzzySearchCaches);
293 const fuzzySearchGroupsResults = fuzzySearchGroups(searchValue);319 const fuzzySearchGroupsResults = fuzzySearchGroups(searchValue, this.fuzzySearchCaches);
294 const fuzzySearchTagsResult = fuzzySearchTags(searchValue);320 const fuzzySearchTagsResult = fuzzySearchTags(searchValue, this.fuzzySearchCaches);
295 this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchCharactersResults.map(i => [`character.${i.refIndex}`, i.score])));321 this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchCharactersResults.map(i => [`character.${i.refIndex}`, i.score])));
296 this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchGroupsResults.map(i => [`group.${i.item.id}`, i.score])));322 this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchGroupsResults.map(i => [`group.${i.item.id}`, i.score])));
297 this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchTagsResult.map(i => [`tag.${i.item.id}`, i.score])));323 this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchTagsResult.map(i => [`tag.${i.item.id}`, i.score])));
@@ -343,11 +369,14 @@ export class FilterHelper {
343 * @param {object} options - Optional call parameters369 * @param {object} options - Optional call parameters
344 * @param {boolean} [options.clearScoreCache=true] - Whether the score cache should be cleared.370 * @param {boolean} [options.clearScoreCache=true] - Whether the score cache should be cleared.
345 * @param {Object.<FilterType, any>} [options.tempOverrides={}] - Temporarily override specific filters for this filter application371 * @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.
346 * @returns {any[]} The filtered data.373 * @returns {any[]} The filtered data.
347 */374 */
348 applyFilters(data, { clearScoreCache = true, tempOverrides = {} } = {}) {375 applyFilters(data, { clearScoreCache = true, tempOverrides = {}, clearFuzzySearchCaches = true } = {}) {
349 if (clearScoreCache) this.clearScoreCache();376 if (clearScoreCache) this.clearScoreCache();
350377
378 if (clearFuzzySearchCaches) this.clearFuzzySearchCaches();
379
351 // Save original filter states380 // Save original filter states
352 const originalStates = {};381 const originalStates = {};
353 for (const key in tempOverrides) {382 for (const key in tempOverrides) {
@@ -411,4 +440,14 @@ export class FilterHelper {
411 this.scoreCache = new Map();440 this.scoreCache = new Map();
412 }441 }
413 }442 }
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 }
414}453}
public/scripts/power-user.js+87 -84
@@ -53,6 +53,7 @@ import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandE
53import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';53import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
54import { POPUP_TYPE, callGenericPopup } from './popup.js';54import { POPUP_TYPE, callGenericPopup } from './popup.js';
55import { loadSystemPrompts } from './sysprompt.js';55import { loadSystemPrompts } from './sysprompt.js';
56import { fuzzySearchCategories } from './filters.js';
5657
57export {58export {
58 loadPowerUserSettings,59 loadPowerUserSettings,
@@ -1829,27 +1830,28 @@ async function loadContextSettings() {
1829 });1830 });
1830}1831}
18311832
1833
1832/**1834/**
1833 * Fuzzy search characters by a search term1835 * Common function to perform fuzzy search with 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
1834 * @param {string} searchValue - The search term1839 * @param {string} searchValue - The search term
1840 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
1835 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score1841 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
1836 */1842 */
1837export function fuzzySearchCharacters(searchValue) {1843function 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
1838 // @ts-ignore1852 // @ts-ignore
1839 const fuse = new Fuse(characters, {1853 const fuse = new Fuse(data, {
1840 keys: [1854 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 ],
1853 includeScore: true,1855 includeScore: true,
1854 ignoreLocation: true,1856 ignoreLocation: true,
1855 useExtendedSearch: true,1857 useExtendedSearch: true,
@@ -1857,109 +1859,110 @@ export function fuzzySearchCharacters(searchValue) {
1857 });1859 });
18581860
1859 const results = fuse.search(searchValue);1861 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 }
1861 return results;1867 return results;
1862}1868}
18631869
1864/**1870/**
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 */
1876export 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/**
1865 * Fuzzy search world info entries by a search term1895 * Fuzzy search world info entries by a search term
1866 * @param {*[]} data - WI items data array1896 * @param {*[]} data - WI items data array
1867 * @param {string} searchValue - The search term1897 * @param {string} searchValue - The search term
1898 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
1868 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score1899 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
1869 */1900 */
1870export function fuzzySearchWorldInfo(data, searchValue) {1901export function fuzzySearchWorldInfo(data, searchValue, fuzzySearchCaches = null) {
1871 // @ts-ignore1902 const keys = [
1872 const fuse = new Fuse(data, {1903 { name: 'key', weight: 20 },
1873 keys: [1904 { name: 'group', weight: 15 },
1874 { name: 'key', weight: 20 },1905 { name: 'comment', weight: 10 },
1875 { name: 'group', weight: 15 },1906 { name: 'keysecondary', weight: 10 },
1876 { name: 'comment', weight: 10 },1907 { name: 'content', weight: 3 },
1877 { name: 'keysecondary', weight: 10 },1908 { name: 'uid', weight: 1 },
1878 { name: 'content', weight: 3 },1909 { name: 'automationId', weight: 1 },
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;
1891}1913}
18921914
1893/**1915/**
1894 * Fuzzy search persona entries by a search term1916 * Fuzzy search persona entries by a search term
1895 * @param {*[]} data - persona data array1917 * @param {*[]} data - persona data array
1896 * @param {string} searchValue - The search term1918 * @param {string} searchValue - The search term
1919 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
1897 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score1920 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
1898 */1921 */
1899export function fuzzySearchPersonas(data, searchValue) {1922export 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-ignore1924 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
1913 const results = fuse.search(searchValue);1929 const keys = [
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);
1916}1935}
19171936
1918/**1937/**
1919 * Fuzzy search tags by a search term1938 * Fuzzy search tags by a search term
1920 * @param {string} searchValue - The search term1939 * @param {string} searchValue - The search term
1940 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
1921 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score1941 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
1922 */1942 */
1923export function fuzzySearchTags(searchValue) {1943export function fuzzySearchTags(searchValue, fuzzySearchCaches = null) {
1924 // @ts-ignore1944 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;
1938}1949}
19391950
1940/**1951/**
1941 * Fuzzy search groups by a search term1952 * Fuzzy search groups by a search term
1942 * @param {string} searchValue - The search term1953 * @param {string} searchValue - The search term
1954 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
1943 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score1955 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score
1944 */1956 */
1945export function fuzzySearchGroups(searchValue) {1957export function fuzzySearchGroups(searchValue, fuzzySearchCaches = null) {
1946 // @ts-ignore1958 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('||') },
1950 { name: 'members', weight: 15 },1962 { name: 'id', weight: 1 },
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;
1963}1966}
19641967
1965/**1968/**