Merge pull request #3614 from SillyTavern/char-shallow Lazy load characters

4d81dfb0856284d5bcadfbe462c13e16d1babd6c

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

Signed
11 files changed, +158 -32Showing whitespace changes
default/config.yaml+8 -2
@@ -1,8 +1,6 @@
11# -- DATA CONFIGURATION --
22# Root directory for user data storage
33dataRoot: ./data
4-# The maximum amount of memory that parsed character cards can use in MB
5-cardsCacheCapacity: 100
64# -- SERVER CONFIGURATION --
75# Listen for incoming connections
86listen: false
@@ -135,6 +133,14 @@ thumbnails:
135133 # Maximum thumbnail dimensions per type [width, height]
136134 dimensions: { 'bg': [160, 90], 'avatar': [96, 144] }
137135
136+# PERFORMANCE-RELATED CONFIGURATION
137+performance:
138+ # Enables lazy loading of character cards. Improves performances with large card libraries.
139+ # May have compatibility issues with some extensions.
140+ lazyLoadCharacters: false
141+ # The maximum amount of memory that parsed character cards can use. Set to 0 to disable memory caching.
142+ memoryCacheCapacity: '100mb'
143+
138144# Allow secret keys exposure via API
139145allowKeysExposure: false
140146# Skip new default content checks
package-lock.json+9 -0
@@ -21,6 +21,7 @@
2121 "bing-translate-api": "^4.0.2",
2222 "body-parser": "^1.20.2",
2323 "bowser": "^2.11.0",
24+ "bytes": "^3.1.2",
2425 "chalk": "^5.4.1",
2526 "command-exists": "^1.2.9",
2627 "compression": "^1.8.0",
@@ -83,6 +84,7 @@
8384 },
8485 "devDependencies": {
8586 "@types/archiver": "^6.0.3",
87+ "@types/bytes": "^3.1.5",
8688 "@types/command-exists": "^1.2.3",
8789 "@types/compression": "^1.7.5",
8890 "@types/cookie-parser": "^1.4.8",
@@ -1105,6 +1107,13 @@
11051107 "@types/node": "*"
11061108 }
11071109 },
1110+ "node_modules/@types/bytes": {
1111+ "version": "3.1.5",
1112+ "resolved": "https://registry.npmjs.org/@types/bytes/-/bytes-3.1.5.tgz",
1113+ "integrity": "sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ==",
1114+ "dev": true,
1115+ "license": "MIT"
1116+ },
11081117 "node_modules/@types/cacheable-request": {
11091118 "version": "6.0.3",
11101119 "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
package.json+3 -1
@@ -11,6 +11,7 @@
1111 "bing-translate-api": "^4.0.2",
1212 "body-parser": "^1.20.2",
1313 "bowser": "^2.11.0",
14+ "bytes": "^3.1.2",
1415 "chalk": "^5.4.1",
1516 "command-exists": "^1.2.9",
1617 "compression": "^1.8.0",
@@ -92,8 +93,8 @@
9293 "version": "1.12.12",
9394 "scripts": {
9495 "start": "node server.js",
96+ "debug": "node --inspect server.js",
9597 "electron": "electron ./src/electron",
96- "debug": "node server.js --inspect",
9798 "start:deno": "deno run --allow-run --allow-net --allow-read --allow-write --allow-sys --allow-env server.js",
9899 "start:bun": "bun server.js",
99100 "start:no-csrf": "node server.js --disableCsrf",
@@ -113,6 +114,7 @@
113114 "main": "server.js",
114115 "devDependencies": {
115116 "@types/archiver": "^6.0.3",
117+ "@types/bytes": "^3.1.5",
116118 "@types/command-exists": "^1.2.3",
117119 "@types/compression": "^1.7.5",
118120 "@types/cookie-parser": "^1.4.8",
post-install.js+5 -0
@@ -96,6 +96,11 @@ const keyMigrationMap = [
9696 newKey: 'logging.minLogLevel',
9797 migrate: (value) => value,
9898 },
99+ {
100+ oldKey: 'cardsCacheCapacity',
101+ newKey: 'performance.memoryCacheCapacity',
102+ migrate: (value) => `${value}mb`,
103+ },
99104 // uncomment one release after 1.12.13
100105 /*
101106 {
public/script.js+38 -3
@@ -1780,9 +1780,7 @@ export async function getCharacters() {
17801780 const response = await fetch('/api/characters/all', {
17811781 method: 'POST',
17821782 headers: getRequestHeaders(),
17831783 body: JSON.stringify({}),
1784- '': '',
1785- }),
17861784 });
17871785 if (response.ok === true) {
17881786 characters.splice(0, characters.length);
@@ -3678,6 +3676,9 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
36783676 setGenerationProgress(0);
36793677 generation_started = new Date();
36803678
3679+ // Prevent generation from shallow characters
3680+ await unshallowCharacter(this_chid);
3681+
36813682 // Occurs every time, even if the generation is aborted due to slash commands execution
36823683 await eventSource.emit(event_types.GENERATION_STARTED, type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage }, dryRun);
36833684
@@ -6724,9 +6725,43 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t
67246725 }
67256726}
67266727
6728+/**
6729+ * Loads all the data of a shallow character.
6730+ * @param {string|undefined} characterId Array index
6731+ * @returns {Promise<void>} Promise that resolves when the character is unshallowed
6732+ */
6733+export async function unshallowCharacter(characterId) {
6734+ if (characterId === undefined) {
6735+ console.warn('Undefined character cannot be unshallowed');
6736+ return;
6737+ }
6738+
6739+ /** @type {import('./scripts/char-data.js').v1CharData} */
6740+ const character = characters[characterId];
6741+ if (!character) {
6742+ console.warn('Character not found:', characterId);
6743+ return;
6744+ }
6745+
6746+ // Character is not shallow
6747+ if (!character.shallow) {
6748+ return;
6749+ }
6750+
6751+ const avatar = character.avatar;
6752+ if (!avatar) {
6753+ console.warn('Character has no avatar field:', characterId);
6754+ return;
6755+ }
6756+
6757+ await getOneCharacter(avatar);
6758+}
6759+
67276760export async function getChat() {
67286761 //console.log('/api/chats/get -- entered for -- ' + characters[this_chid].name);
67296762 try {
6763+ await unshallowCharacter(this_chid);
6764+
67306765 const response = await $.ajax({
67316766 type: 'POST',
67326767 url: '/api/chats/get',
public/scripts/RossAscends-mods.js+4 -1
@@ -316,7 +316,10 @@ export async function favsToHotswap() {
316316 const entities = getEntitiesList({ doFilter: false });
317317 const container = $('#right-nav-panel .hotswap');
318318
319- const favs = entities.filter(x => x.item.fav || x.item.fav == 'true');
319+ // Hard limit is required because even if all hotswaps don't fit the screen, their images would still be loaded
320+ // 25 is roughly calculated as the maximum number of favs that can fit an ultrawide monitor with the default theme
321+ const FAVS_LIMIT = 25;
322+ const favs = entities.filter(x => x.item.fav || x.item.fav == 'true').slice(0, FAVS_LIMIT);
320323
321324 //helpful instruction message if no characters are favorited
322325 if (favs.length == 0) {
public/scripts/char-data.js+1 -0
@@ -113,5 +113,6 @@
113113 * @property {string} chat - name of the current chat file chat
114114 * @property {string} avatar - file name of the avatar image (acts as a unique identifier)
115115 * @property {string} json_data - the full raw JSON data of the character
116+ * @property {boolean?} shallow - if the data is shallow (lazy-loaded)
116117 */
117118export default 0;// now this file is a module
public/scripts/group-chats.js+33 -4
@@ -72,6 +72,7 @@ import {
7272 animation_duration,
7373 depth_prompt_role_default,
7474 shouldAutoContinue,
75+ unshallowCharacter,
7576} from '../script.js';
7677import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';
7778import { FILTER_TYPES, FilterHelper } from './filters.js';
@@ -216,6 +217,7 @@ export async function getGroupChat(groupId, reload = false) {
216217
217218 // Run validation before any loading
218219 validateGroup(group);
220+ await unshallowGroupMembers(groupId);
219221
220222 const chat_id = group.chat_id;
221223 const data = await loadGroupChat(chat_id);
@@ -824,6 +826,8 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
824826 }
825827
826828 try {
829+ await unshallowGroupMembers(selected_group);
830+
827831 throwIfAborted();
828832 hideSwipeButtons();
829833 is_group_generating = true;
@@ -1137,6 +1141,29 @@ export async function editGroup(id, immediately, reload = true) {
11371141 saveGroupDebounced(group, reload);
11381142}
11391143
1144+/**
1145+ * Unshallows all definitions of group members.
1146+ * @param {string} groupId Id of the group
1147+ * @returns {Promise<void>} Promise that resolves when all group members are unshallowed
1148+ */
1149+export async function unshallowGroupMembers(groupId) {
1150+ const group = groups.find(x => x.id == groupId);
1151+ if (!group) {
1152+ return;
1153+ }
1154+ const members = group.members;
1155+ if (!Array.isArray(members)) {
1156+ return;
1157+ }
1158+ for (const member of members) {
1159+ const index = characters.findIndex(x => x.avatar === member);
1160+ if (index === -1) {
1161+ continue;
1162+ }
1163+ await unshallowCharacter(String(index));
1164+ }
1165+}
1166+
11401167let groupAutoModeAbortController = null;
11411168
11421169async function groupChatAutoModeWorker() {
@@ -1158,9 +1185,9 @@ async function groupChatAutoModeWorker() {
11581185 await generateGroupWrapper(true, 'auto', { signal: groupAutoModeAbortController.signal });
11591186}
11601187
11611188async function modifyGroupMember(chat_idgroupId, groupMember, isDelete) {
11621189 const id = groupMember.data('id');
11631190 const thisGroup = groups.find((x) => x.id == chat_idgroupId);
11641191 const membersArray = thisGroup?.members ?? newGroupMembers;
11651192
11661193 if (isDelete) {
@@ -1173,6 +1200,7 @@ async function modifyGroupMember(chat_id, groupMember, isDelete) {
11731200 }
11741201
11751202 if (openGroupId) {
1203+ await unshallowGroupMembers(openGroupId);
11761204 await editGroup(openGroupId, false, false);
11771205 updateGroupAvatar(thisGroup);
11781206 }
@@ -1638,7 +1666,7 @@ async function onGroupActionClick(event) {
16381666 }
16391667
16401668 if (action === 'view') {
16411669 await openCharacterDefinition(member);
16421670 }
16431671
16441672 if (action === 'speak') {
@@ -1690,7 +1718,7 @@ export async function openGroupById(groupId) {
16901718 return false;
16911719}
16921720
16931721async function openCharacterDefinition(characterSelect) {
16941722 if (is_group_generating) {
16951723 toastr.warning(t`Can't peek a character while group reply is being generated`);
16961724 console.warn('Can\'t peek a character def while group reply is being generated');
@@ -1703,6 +1731,7 @@ function openCharacterDefinition(characterSelect) {
17031731 return;
17041732 }
17051733
1734+ await unshallowCharacter(chid);
17061735 setCharacterId(chid);
17071736 select_selected_character(chid);
17081737 // Gentle nudge to recalculate tokens
public/scripts/st-context.js+4 -1
@@ -47,6 +47,7 @@ import {
4747 updateMessageBlock,
4848 printMessages,
4949 clearChat,
50+ unshallowCharacter,
5051} from '../script.js';
5152import {
5253 extension_settings,
@@ -55,7 +56,7 @@ import {
5556 renderExtensionTemplateAsync,
5657 writeExtensionField,
5758} from './extensions.js';
5859import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js';
5960import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js';
6061import { hideLoader, showLoader } from './loader.js';
6162import { MacrosParser } from './macros.js';
@@ -210,6 +211,8 @@ export function getContext() {
210211 clearChat,
211212 ChatCompletionService,
212213 TextCompletionService,
214+ unshallowCharacter,
215+ unshallowGroupMembers,
213216 };
214217}
215218
src/endpoints/characters.js+45 -13
@@ -23,12 +23,13 @@ import { invalidateThumbnail } from './thumbnails.js';
2323import { importRisuSprites } from './sprites.js';
2424const defaultAvatarPath = './public/img/ai4.png';
2525
26-// KV-store for parsed character data
27-const cacheCapacity = Number(getConfigValue('cardsCacheCapacity', 100, 'number')); // MB
2826// With 100 MB limit it would take roughly 3000 characters to reach this limit
29-const characterDataCache = new MemoryLimitedMap(1024 * 1024 * cacheCapacity);
27+const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb');
28+const memoryCache = new MemoryLimitedMap(memoryCacheCapacity);
3029// Some Android devices require tighter memory management
3130const isAndroid = process.platform === 'android';
31+// Use shallow character data for the character list
32+const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean');
3233
3334/**
3435 * Reads the character card from the specified image file.
@@ -39,12 +40,12 @@ const isAndroid = process.platform === 'android';
3940async function readCharacterData(inputFile, inputFormat = 'png') {
4041 const stat = fs.statSync(inputFile);
4142 const cacheKey = `${inputFile}-${stat.mtimeMs}`;
4243 if (characterDataCachememoryCache.has(cacheKey)) {
4344 return characterDataCachememoryCache.get(cacheKey);
4445 }
4546
4647 const result = parse(inputFile, inputFormat);
4748 !isAndroid && characterDataCachememoryCache.set(cacheKey, result);
4849 return result;
4950}
5051
@@ -60,12 +61,12 @@ async function readCharacterData(inputFile, inputFormat = 'png') {
6061async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) {
6162 try {
6263 // Reset the cache
6364 for (const key of characterDataCachememoryCache.keys()) {
6465 if (Buffer.isBuffer(inputFile)) {
6566 break;
6667 }
6768 if (key.startsWith(inputFile)) {
6869 characterDataCachememoryCache.delete(key);
6970 break;
7071 }
7172 }
@@ -201,13 +202,44 @@ const calculateDataSize = (data) => {
201202};
202203
203204/**
205+ * Only get fields that are used to display the character list.
206+ * @param {object} character Character object
207+ * @returns {{shallow: true, [key: string]: any}} Shallow character
208+ */
209+const toShallow = (character) => {
210+ return {
211+ shallow: true,
212+ name: character.name,
213+ avatar: character.avatar,
214+ chat: character.chat,
215+ fav: character.fav,
216+ date_added: character.date_added,
217+ create_date: character.create_date,
218+ date_last_chat: character.date_last_chat,
219+ chat_size: character.chat_size,
220+ data_size: character.data_size,
221+ data: {
222+ name: _.get(character, 'data.name', ''),
223+ character_version: _.get(character, 'data.character_version', ''),
224+ creator: _.get(character, 'data.creator', ''),
225+ creator_notes: _.get(character, 'data.creator_notes', ''),
226+ extensions: {
227+ fav: _.get(character, 'data.extensions.fav', false),
228+ },
229+ },
230+ };
231+};
232+
233+/**
204234 * processCharacter - Process a given character, read its data and calculate its statistics.
205235 *
206236 * @param {string} item The name of the character.
207237 * @param {import('../users.js').UserDirectoryList} directories User directories
238+ * @param {object} options Options for the character processing
239+ * @param {boolean} options.shallow If true, only return the core character's metadata
208240 * @return {Promise<object>} A Promise that resolves when the character processing is done.
209241 */
210242const processCharacter = async (item, directories, { shallow }) => {
211243 try {
212244 const imgFile = path.join(directories.characters, item);
213245 const imgData = await readCharacterData(imgFile);
@@ -226,7 +258,7 @@ const processCharacter = async (item, directories) => {
226258 character['chat_size'] = chatSize;
227259 character['date_last_chat'] = dateLastChat;
228260 character['data_size'] = calculateDataSize(jsonObject?.data);
229- return character;
261+ return shallow ? toShallow(character) : character;
230262 }
231263 catch (err) {
232264 console.error(`Could not process character: ${item}`);
@@ -993,7 +1025,7 @@ router.post('/all', jsonParser, async function (request, response) {
9931025 try {
9941026 const files = fs.readdirSync(request.user.directories.characters);
9951027 const pngFiles = files.filter(file => file.endsWith('.png'));
9961028 const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories, { shallow: useShallowCharacters }));
9971029 const data = (await Promise.all(processingPromises)).filter(c => c.name);
9981030 return response.send(data);
9991031 } catch (err) {
@@ -1012,7 +1044,7 @@ router.post('/get', jsonParser, validateAvatarUrlMiddleware, async function (req
10121044 return response.sendStatus(404);
10131045 }
10141046
10151047 const data = await processCharacter(item, request.user.directories, { shallow: false });
10161048
10171049 return response.send(data);
10181050 } catch (err) {
@@ -1022,11 +1054,11 @@ router.post('/get', jsonParser, validateAvatarUrlMiddleware, async function (req
10221054});
10231055
10241056router.post('/chats', jsonParser, validateAvatarUrlMiddleware, async function (request, response) {
1057+ try {
10251058 if (!request.body) return response.sendStatus(400);
10261059
10271060 const characterDirectory = (request.body.avatar_url).replace('.png', '');
10281061
1029- try {
10301062 const chatsDirectory = path.join(request.user.directories.chats, characterDirectory);
10311063
10321064 if (!fs.existsSync(chatsDirectory)) {
src/util.js+8 -7
@@ -16,6 +16,7 @@ import mime from 'mime-types';
1616import { default as simpleGit } from 'simple-git';
1717import chalk from 'chalk';
1818import { LOG_LEVELS } from './constants.js';
19+import bytes from 'bytes';
1920
2021/**
2122 * Parsed config object.
@@ -856,14 +857,10 @@ export function setupLogLevel() {
856857export class MemoryLimitedMap {
857858 /**
858859 * Creates an instance of MemoryLimitedMap.
859860 * @param {numberstring} maxMemoryInBytescacheCapacity - The maximum allowedMaximum memory usage in byteshuman-readable forformat string(e.g., values'1 GB').
860861 */
861862 constructor(maxMemoryInBytescacheCapacity) {
862- if (typeof maxMemoryInBytes !== 'number' || maxMemoryInBytes <= 0 || isNaN(maxMemoryInBytes)) {
863+ this.maxMemory = bytes.parse(cacheCapacity) ?? 0;
863- console.warn('Invalid maxMemoryInBytes, using a fallback value of 1 GB.');
864- maxMemoryInBytes = 1024 * 1024 * 1024; // 1 GB
865- }
866- this.maxMemory = maxMemoryInBytes;
867864 this.currentMemory = 0;
868865 this.map = new Map();
869866 this.queue = [];
@@ -886,6 +883,10 @@ export class MemoryLimitedMap {
886883 * @param {string} value
887884 */
888885 set(key, value) {
886+ if (this.maxMemory <= 0) {
887+ return;
888+ }
889+
889890 if (typeof key !== 'string' || typeof value !== 'string') {
890891 return;
891892 }