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, +118 -75Showing whitespace changes
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+69 -66
@@ -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,15 +1830,51 @@ 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,
1855 includeScore: true,
1856 ignoreLocation: true,
1857 useExtendedSearch: true,
1858 threshold: 0.2,
1859 });
1860
1861 const results = fuse.search(searchValue);
1862
1863 // Store in cache if provided
1864 if (fuzzySearchCaches) {
1865 fuzzySearchCaches[type].resultMap.set(searchValue, results);
1866 }
1867 return results;
1868}
1869
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 = [
1841 { name: 'data.name', weight: 20 },1878 { name: 'data.name', weight: 20 },
1842 { name: '#tags', weight: 10, getFn: (character) => getTagsList(character.avatar).map(x => x.name).join('||') },1879 { name: '#tags', weight: 10, getFn: (character) => getTagsList(character.avatar).map(x => x.name).join('||') },
1843 { name: 'data.description', weight: 3 },1880 { name: 'data.description', weight: 3 },
@@ -1849,28 +1886,20 @@ export function fuzzySearchCharacters(searchValue) {
1849 { name: 'data.creator', weight: 1 },1886 { name: 'data.creator', weight: 1 },
1850 { name: 'data.tags', weight: 1 },1887 { name: 'data.tags', weight: 1 },
1851 { name: 'data.alternate_greetings', weight: 1 },1888 { name: 'data.alternate_greetings', weight: 1 },
1852 ],1889 ];
1853 includeScore: true,
1854 ignoreLocation: true,
1855 useExtendedSearch: true,
1856 threshold: 0.2,
1857 });
18581890
1859 const results = fuse.search(searchValue);1891 return performFuzzySearch(fuzzySearchCategories.characters, characters, keys, searchValue, fuzzySearchCaches);
1860 console.debug('Characters fuzzy search results for ' + searchValue, results);
1861 return results;
1862}1892}
18631893
1864/**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, {
1873 keys: [
1874 { name: 'key', weight: 20 },1903 { name: 'key', weight: 20 },
1875 { name: 'group', weight: 15 },1904 { name: 'group', weight: 15 },
1876 { name: 'comment', weight: 10 },1905 { name: 'comment', weight: 10 },
@@ -1878,88 +1907,62 @@ export function fuzzySearchWorldInfo(data, searchValue) {
1878 { name: 'content', weight: 3 },1907 { name: 'content', weight: 3 },
1879 { name: 'uid', weight: 1 },1908 { name: 'uid', weight: 1 },
1880 { name: 'automationId', weight: 1 },1909 { name: 'automationId', weight: 1 },
1881 ],1910 ];
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 ?? ''
1927 }));
1928
1929 const keys = [
1904 { name: 'name', weight: 20 },1930 { name: 'name', weight: 20 },
1905 { name: 'description', weight: 3 },1931 { name: 'description', weight: 3 },
1906 ],1932 ];
1907 includeScore: true,
1908 ignoreLocation: true,
1909 useExtendedSearch: true,
1910 threshold: 0.2,
1911 });
19121933
1913 const results = fuse.search(searchValue);1934 return performFuzzySearch(fuzzySearchCategories.personas, mappedData, keys, searchValue, fuzzySearchCaches);
1914 console.debug('Personas fuzzy search results for ' + searchValue, results);
1915 return results;
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, {
1926 keys: [
1927 { name: 'name', weight: 1 },1945 { name: 'name', weight: 1 },
1928 ],1946 ];
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, {
1948 keys: [
1949 { name: 'name', weight: 20 },1959 { name: 'name', weight: 20 },
1950 { name: 'members', weight: 15 },1960 { name: 'members', weight: 15 },
1951 { name: '#tags', weight: 10, getFn: (group) => getTagsList(group.id).map(x => x.name).join('||') },1961 { name: '#tags', weight: 10, getFn: (group) => getTagsList(group.id).map(x => x.name).join('||') },
1952 { name: 'id', weight: 1 },1962 { name: 'id', weight: 1 },
1953 ],1963 ];
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/**