Merge pull request #3614 from SillyTavern/char-shallow Lazy load characters
Signed| @@ -1,8 +1,6 @@ | |||
| 1 | # -- DATA CONFIGURATION -- | 1 | # -- DATA CONFIGURATION -- |
| 2 | # Root directory for user data storage | 2 | # Root directory for user data storage |
| 3 | dataRoot: ./data | 3 | dataRoot: ./data |
| 4 | # The maximum amount of memory that parsed character cards can use in MB | ||
| 5 | cardsCacheCapacity: 100 | ||
| 6 | # -- SERVER CONFIGURATION -- | 4 | # -- SERVER CONFIGURATION -- |
| 7 | # Listen for incoming connections | 5 | # Listen for incoming connections |
| 8 | listen: false | 6 | listen: false |
| @@ -135,6 +133,14 @@ thumbnails: | |||
| 135 | # Maximum thumbnail dimensions per type [width, height] | 133 | # Maximum thumbnail dimensions per type [width, height] |
| 136 | dimensions: { 'bg': [160, 90], 'avatar': [96, 144] } | 134 | dimensions: { 'bg': [160, 90], 'avatar': [96, 144] } |
| 137 | 135 | ||
| 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 | |||
| 138 | # Allow secret keys exposure via API | 144 | # Allow secret keys exposure via API |
| 139 | allowKeysExposure: false | 145 | allowKeysExposure: false |
| 140 | # Skip new default content checks | 146 | # Skip new default content checks |
| @@ -21,6 +21,7 @@ | |||
| 21 | "bing-translate-api": "^4.0.2", | 21 | "bing-translate-api": "^4.0.2", |
| 22 | "body-parser": "^1.20.2", | 22 | "body-parser": "^1.20.2", |
| 23 | "bowser": "^2.11.0", | 23 | "bowser": "^2.11.0", |
| 24 | "bytes": "^3.1.2", | ||
| 24 | "chalk": "^5.4.1", | 25 | "chalk": "^5.4.1", |
| 25 | "command-exists": "^1.2.9", | 26 | "command-exists": "^1.2.9", |
| 26 | "compression": "^1.8.0", | 27 | "compression": "^1.8.0", |
| @@ -83,6 +84,7 @@ | |||
| 83 | }, | 84 | }, |
| 84 | "devDependencies": { | 85 | "devDependencies": { |
| 85 | "@types/archiver": "^6.0.3", | 86 | "@types/archiver": "^6.0.3", |
| 87 | "@types/bytes": "^3.1.5", | ||
| 86 | "@types/command-exists": "^1.2.3", | 88 | "@types/command-exists": "^1.2.3", |
| 87 | "@types/compression": "^1.7.5", | 89 | "@types/compression": "^1.7.5", |
| 88 | "@types/cookie-parser": "^1.4.8", | 90 | "@types/cookie-parser": "^1.4.8", |
| @@ -1105,6 +1107,13 @@ | |||
| 1105 | "@types/node": "*" | 1107 | "@types/node": "*" |
| 1106 | } | 1108 | } |
| 1107 | }, | 1109 | }, |
| 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 | }, | ||
| 1108 | "node_modules/@types/cacheable-request": { | 1117 | "node_modules/@types/cacheable-request": { |
| 1109 | "version": "6.0.3", | 1118 | "version": "6.0.3", |
| 1110 | "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", | 1119 | "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", |
| @@ -11,6 +11,7 @@ | |||
| 11 | "bing-translate-api": "^4.0.2", | 11 | "bing-translate-api": "^4.0.2", |
| 12 | "body-parser": "^1.20.2", | 12 | "body-parser": "^1.20.2", |
| 13 | "bowser": "^2.11.0", | 13 | "bowser": "^2.11.0", |
| 14 | "bytes": "^3.1.2", | ||
| 14 | "chalk": "^5.4.1", | 15 | "chalk": "^5.4.1", |
| 15 | "command-exists": "^1.2.9", | 16 | "command-exists": "^1.2.9", |
| 16 | "compression": "^1.8.0", | 17 | "compression": "^1.8.0", |
| @@ -92,8 +93,8 @@ | |||
| 92 | "version": "1.12.12", | 93 | "version": "1.12.12", |
| 93 | "scripts": { | 94 | "scripts": { |
| 94 | "start": "node server.js", | 95 | "start": "node server.js", |
| 96 | "debug": "node --inspect server.js", | ||
| 95 | "electron": "electron ./src/electron", | 97 | "electron": "electron ./src/electron", |
| 96 | "debug": "node server.js --inspect", | ||
| 97 | "start:deno": "deno run --allow-run --allow-net --allow-read --allow-write --allow-sys --allow-env server.js", | 98 | "start:deno": "deno run --allow-run --allow-net --allow-read --allow-write --allow-sys --allow-env server.js", |
| 98 | "start:bun": "bun server.js", | 99 | "start:bun": "bun server.js", |
| 99 | "start:no-csrf": "node server.js --disableCsrf", | 100 | "start:no-csrf": "node server.js --disableCsrf", |
| @@ -113,6 +114,7 @@ | |||
| 113 | "main": "server.js", | 114 | "main": "server.js", |
| 114 | "devDependencies": { | 115 | "devDependencies": { |
| 115 | "@types/archiver": "^6.0.3", | 116 | "@types/archiver": "^6.0.3", |
| 117 | "@types/bytes": "^3.1.5", | ||
| 116 | "@types/command-exists": "^1.2.3", | 118 | "@types/command-exists": "^1.2.3", |
| 117 | "@types/compression": "^1.7.5", | 119 | "@types/compression": "^1.7.5", |
| 118 | "@types/cookie-parser": "^1.4.8", | 120 | "@types/cookie-parser": "^1.4.8", |
| @@ -96,6 +96,11 @@ const keyMigrationMap = [ | |||
| 96 | newKey: 'logging.minLogLevel', | 96 | newKey: 'logging.minLogLevel', |
| 97 | migrate: (value) => value, | 97 | migrate: (value) => value, |
| 98 | }, | 98 | }, |
| 99 | { | ||
| 100 | oldKey: 'cardsCacheCapacity', | ||
| 101 | newKey: 'performance.memoryCacheCapacity', | ||
| 102 | migrate: (value) => `${value}mb`, | ||
| 103 | }, | ||
| 99 | // uncomment one release after 1.12.13 | 104 | // uncomment one release after 1.12.13 |
| 100 | /* | 105 | /* |
| 101 | { | 106 | { |
| @@ -1780,9 +1780,7 @@ export async function getCharacters() { | |||
| 1780 | const response = await fetch('/api/characters/all', { | 1780 | const response = await fetch('/api/characters/all', { |
| 1781 | method: 'POST', | 1781 | method: 'POST', |
| 1782 | headers: getRequestHeaders(), | 1782 | headers: getRequestHeaders(), |
| 1783 | body: JSON.stringify({ | 1783 | body: JSON.stringify({}), |
| 1784 | '': '', | ||
| 1785 | }), | ||
| 1786 | }); | 1784 | }); |
| 1787 | if (response.ok === true) { | 1785 | if (response.ok === true) { |
| 1788 | characters.splice(0, characters.length); | 1786 | characters.splice(0, characters.length); |
| @@ -3678,6 +3676,9 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | |||
| 3678 | setGenerationProgress(0); | 3676 | setGenerationProgress(0); |
| 3679 | generation_started = new Date(); | 3677 | generation_started = new Date(); |
| 3680 | 3678 | ||
| 3679 | // Prevent generation from shallow characters | ||
| 3680 | await unshallowCharacter(this_chid); | ||
| 3681 | |||
| 3681 | // Occurs every time, even if the generation is aborted due to slash commands execution | 3682 | // Occurs every time, even if the generation is aborted due to slash commands execution |
| 3682 | await eventSource.emit(event_types.GENERATION_STARTED, type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage }, dryRun); | 3683 | await eventSource.emit(event_types.GENERATION_STARTED, type, { automatic_trigger, force_name2, quiet_prompt, quietToLoud, skipWIAN, force_chid, signal, quietImage }, dryRun); |
| 3683 | 3684 | ||
| @@ -6724,9 +6725,43 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t | |||
| 6724 | } | 6725 | } |
| 6725 | } | 6726 | } |
| 6726 | 6727 | ||
| 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 | |||
| 6727 | export async function getChat() { | 6760 | export async function getChat() { |
| 6728 | //console.log('/api/chats/get -- entered for -- ' + characters[this_chid].name); | 6761 | //console.log('/api/chats/get -- entered for -- ' + characters[this_chid].name); |
| 6729 | try { | 6762 | try { |
| 6763 | await unshallowCharacter(this_chid); | ||
| 6764 | |||
| 6730 | const response = await $.ajax({ | 6765 | const response = await $.ajax({ |
| 6731 | type: 'POST', | 6766 | type: 'POST', |
| 6732 | url: '/api/chats/get', | 6767 | url: '/api/chats/get', |
| @@ -316,7 +316,10 @@ export async function favsToHotswap() { | |||
| 316 | const entities = getEntitiesList({ doFilter: false }); | 316 | const entities = getEntitiesList({ doFilter: false }); |
| 317 | const container = $('#right-nav-panel .hotswap'); | 317 | const container = $('#right-nav-panel .hotswap'); |
| 318 | 318 | ||
| 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); | ||
| 320 | 323 | ||
| 321 | //helpful instruction message if no characters are favorited | 324 | //helpful instruction message if no characters are favorited |
| 322 | if (favs.length == 0) { | 325 | if (favs.length == 0) { |
| @@ -113,5 +113,6 @@ | |||
| 113 | * @property {string} chat - name of the current chat file chat | 113 | * @property {string} chat - name of the current chat file chat |
| 114 | * @property {string} avatar - file name of the avatar image (acts as a unique identifier) | 114 | * @property {string} avatar - file name of the avatar image (acts as a unique identifier) |
| 115 | * @property {string} json_data - the full raw JSON data of the character | 115 | * @property {string} json_data - the full raw JSON data of the character |
| 116 | * @property {boolean?} shallow - if the data is shallow (lazy-loaded) | ||
| 116 | */ | 117 | */ |
| 117 | export default 0;// now this file is a module | 118 | export default 0;// now this file is a module |
| @@ -72,6 +72,7 @@ import { | |||
| 72 | animation_duration, | 72 | animation_duration, |
| 73 | depth_prompt_role_default, | 73 | depth_prompt_role_default, |
| 74 | shouldAutoContinue, | 74 | shouldAutoContinue, |
| 75 | unshallowCharacter, | ||
| 75 | } from '../script.js'; | 76 | } from '../script.js'; |
| 76 | import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js'; | 77 | import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js'; |
| 77 | import { FILTER_TYPES, FilterHelper } from './filters.js'; | 78 | import { FILTER_TYPES, FilterHelper } from './filters.js'; |
| @@ -216,6 +217,7 @@ export async function getGroupChat(groupId, reload = false) { | |||
| 216 | 217 | ||
| 217 | // Run validation before any loading | 218 | // Run validation before any loading |
| 218 | validateGroup(group); | 219 | validateGroup(group); |
| 220 | await unshallowGroupMembers(groupId); | ||
| 219 | 221 | ||
| 220 | const chat_id = group.chat_id; | 222 | const chat_id = group.chat_id; |
| 221 | const data = await loadGroupChat(chat_id); | 223 | const data = await loadGroupChat(chat_id); |
| @@ -824,6 +826,8 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) { | |||
| 824 | } | 826 | } |
| 825 | 827 | ||
| 826 | try { | 828 | try { |
| 829 | await unshallowGroupMembers(selected_group); | ||
| 830 | |||
| 827 | throwIfAborted(); | 831 | throwIfAborted(); |
| 828 | hideSwipeButtons(); | 832 | hideSwipeButtons(); |
| 829 | is_group_generating = true; | 833 | is_group_generating = true; |
| @@ -1137,6 +1141,29 @@ export async function editGroup(id, immediately, reload = true) { | |||
| 1137 | saveGroupDebounced(group, reload); | 1141 | saveGroupDebounced(group, reload); |
| 1138 | } | 1142 | } |
| 1139 | 1143 | ||
| 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 | |||
| 1140 | let groupAutoModeAbortController = null; | 1167 | let groupAutoModeAbortController = null; |
| 1141 | 1168 | ||
| 1142 | async function groupChatAutoModeWorker() { | 1169 | async function groupChatAutoModeWorker() { |
| @@ -1158,9 +1185,9 @@ async function groupChatAutoModeWorker() { | |||
| 1158 | await generateGroupWrapper(true, 'auto', { signal: groupAutoModeAbortController.signal }); | 1185 | await generateGroupWrapper(true, 'auto', { signal: groupAutoModeAbortController.signal }); |
| 1159 | } | 1186 | } |
| 1160 | 1187 | ||
| 1161 | async function modifyGroupMember(chat_id, groupMember, isDelete) { | 1188 | async function modifyGroupMember(groupId, groupMember, isDelete) { |
| 1162 | const id = groupMember.data('id'); | 1189 | const id = groupMember.data('id'); |
| 1163 | const thisGroup = groups.find((x) => x.id == chat_id); | 1190 | const thisGroup = groups.find((x) => x.id == groupId); |
| 1164 | const membersArray = thisGroup?.members ?? newGroupMembers; | 1191 | const membersArray = thisGroup?.members ?? newGroupMembers; |
| 1165 | 1192 | ||
| 1166 | if (isDelete) { | 1193 | if (isDelete) { |
| @@ -1173,6 +1200,7 @@ async function modifyGroupMember(chat_id, groupMember, isDelete) { | |||
| 1173 | } | 1200 | } |
| 1174 | 1201 | ||
| 1175 | if (openGroupId) { | 1202 | if (openGroupId) { |
| 1203 | await unshallowGroupMembers(openGroupId); | ||
| 1176 | await editGroup(openGroupId, false, false); | 1204 | await editGroup(openGroupId, false, false); |
| 1177 | updateGroupAvatar(thisGroup); | 1205 | updateGroupAvatar(thisGroup); |
| 1178 | } | 1206 | } |
| @@ -1638,7 +1666,7 @@ async function onGroupActionClick(event) { | |||
| 1638 | } | 1666 | } |
| 1639 | 1667 | ||
| 1640 | if (action === 'view') { | 1668 | if (action === 'view') { |
| 1641 | openCharacterDefinition(member); | 1669 | await openCharacterDefinition(member); |
| 1642 | } | 1670 | } |
| 1643 | 1671 | ||
| 1644 | if (action === 'speak') { | 1672 | if (action === 'speak') { |
| @@ -1690,7 +1718,7 @@ export async function openGroupById(groupId) { | |||
| 1690 | return false; | 1718 | return false; |
| 1691 | } | 1719 | } |
| 1692 | 1720 | ||
| 1693 | function openCharacterDefinition(characterSelect) { | 1721 | async function openCharacterDefinition(characterSelect) { |
| 1694 | if (is_group_generating) { | 1722 | if (is_group_generating) { |
| 1695 | toastr.warning(t`Can't peek a character while group reply is being generated`); | 1723 | toastr.warning(t`Can't peek a character while group reply is being generated`); |
| 1696 | console.warn('Can\'t peek a character def while group reply is being generated'); | 1724 | console.warn('Can\'t peek a character def while group reply is being generated'); |
| @@ -1703,6 +1731,7 @@ function openCharacterDefinition(characterSelect) { | |||
| 1703 | return; | 1731 | return; |
| 1704 | } | 1732 | } |
| 1705 | 1733 | ||
| 1734 | await unshallowCharacter(chid); | ||
| 1706 | setCharacterId(chid); | 1735 | setCharacterId(chid); |
| 1707 | select_selected_character(chid); | 1736 | select_selected_character(chid); |
| 1708 | // Gentle nudge to recalculate tokens | 1737 | // Gentle nudge to recalculate tokens |
| @@ -47,6 +47,7 @@ import { | |||
| 47 | updateMessageBlock, | 47 | updateMessageBlock, |
| 48 | printMessages, | 48 | printMessages, |
| 49 | clearChat, | 49 | clearChat, |
| 50 | unshallowCharacter, | ||
| 50 | } from '../script.js'; | 51 | } from '../script.js'; |
| 51 | import { | 52 | import { |
| 52 | extension_settings, | 53 | extension_settings, |
| @@ -55,7 +56,7 @@ import { | |||
| 55 | renderExtensionTemplateAsync, | 56 | renderExtensionTemplateAsync, |
| 56 | writeExtensionField, | 57 | writeExtensionField, |
| 57 | } from './extensions.js'; | 58 | } from './extensions.js'; |
| 58 | import { groups, openGroupChat, selected_group } from './group-chats.js'; | 59 | import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js'; |
| 59 | import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js'; | 60 | import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js'; |
| 60 | import { hideLoader, showLoader } from './loader.js'; | 61 | import { hideLoader, showLoader } from './loader.js'; |
| 61 | import { MacrosParser } from './macros.js'; | 62 | import { MacrosParser } from './macros.js'; |
| @@ -210,6 +211,8 @@ export function getContext() { | |||
| 210 | clearChat, | 211 | clearChat, |
| 211 | ChatCompletionService, | 212 | ChatCompletionService, |
| 212 | TextCompletionService, | 213 | TextCompletionService, |
| 214 | unshallowCharacter, | ||
| 215 | unshallowGroupMembers, | ||
| 213 | }; | 216 | }; |
| 214 | } | 217 | } |
| 215 | 218 | ||
| @@ -23,12 +23,13 @@ import { invalidateThumbnail } from './thumbnails.js'; | |||
| 23 | import { importRisuSprites } from './sprites.js'; | 23 | import { importRisuSprites } from './sprites.js'; |
| 24 | const defaultAvatarPath = './public/img/ai4.png'; | 24 | const defaultAvatarPath = './public/img/ai4.png'; |
| 25 | 25 | ||
| 26 | // KV-store for parsed character data | ||
| 27 | const cacheCapacity = Number(getConfigValue('cardsCacheCapacity', 100, 'number')); // MB | ||
| 28 | // With 100 MB limit it would take roughly 3000 characters to reach this limit | 26 | // 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); | ||
| 30 | // Some Android devices require tighter memory management | 29 | // Some Android devices require tighter memory management |
| 31 | const isAndroid = process.platform === 'android'; | 30 | const isAndroid = process.platform === 'android'; |
| 31 | // Use shallow character data for the character list | ||
| 32 | const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean'); | ||
| 32 | 33 | ||
| 33 | /** | 34 | /** |
| 34 | * Reads the character card from the specified image file. | 35 | * Reads the character card from the specified image file. |
| @@ -39,12 +40,12 @@ const isAndroid = process.platform === 'android'; | |||
| 39 | async function readCharacterData(inputFile, inputFormat = 'png') { | 40 | async function readCharacterData(inputFile, inputFormat = 'png') { |
| 40 | const stat = fs.statSync(inputFile); | 41 | const stat = fs.statSync(inputFile); |
| 41 | const cacheKey = `${inputFile}-${stat.mtimeMs}`; | 42 | const cacheKey = `${inputFile}-${stat.mtimeMs}`; |
| 42 | if (characterDataCache.has(cacheKey)) { | 43 | if (memoryCache.has(cacheKey)) { |
| 43 | return characterDataCache.get(cacheKey); | 44 | return memoryCache.get(cacheKey); |
| 44 | } | 45 | } |
| 45 | 46 | ||
| 46 | const result = parse(inputFile, inputFormat); | 47 | const result = parse(inputFile, inputFormat); |
| 47 | !isAndroid && characterDataCache.set(cacheKey, result); | 48 | !isAndroid && memoryCache.set(cacheKey, result); |
| 48 | return result; | 49 | return result; |
| 49 | } | 50 | } |
| 50 | 51 | ||
| @@ -60,12 +61,12 @@ async function readCharacterData(inputFile, inputFormat = 'png') { | |||
| 60 | async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) { | 61 | async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) { |
| 61 | try { | 62 | try { |
| 62 | // Reset the cache | 63 | // Reset the cache |
| 63 | for (const key of characterDataCache.keys()) { | 64 | for (const key of memoryCache.keys()) { |
| 64 | if (Buffer.isBuffer(inputFile)) { | 65 | if (Buffer.isBuffer(inputFile)) { |
| 65 | break; | 66 | break; |
| 66 | } | 67 | } |
| 67 | if (key.startsWith(inputFile)) { | 68 | if (key.startsWith(inputFile)) { |
| 68 | characterDataCache.delete(key); | 69 | memoryCache.delete(key); |
| 69 | break; | 70 | break; |
| 70 | } | 71 | } |
| 71 | } | 72 | } |
| @@ -201,13 +202,44 @@ const calculateDataSize = (data) => { | |||
| 201 | }; | 202 | }; |
| 202 | 203 | ||
| 203 | /** | 204 | /** |
| 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 | /** | ||
| 204 | * processCharacter - Process a given character, read its data and calculate its statistics. | 234 | * processCharacter - Process a given character, read its data and calculate its statistics. |
| 205 | * | 235 | * |
| 206 | * @param {string} item The name of the character. | 236 | * @param {string} item The name of the character. |
| 207 | * @param {import('../users.js').UserDirectoryList} directories User directories | 237 | * @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 | ||
| 208 | * @return {Promise<object>} A Promise that resolves when the character processing is done. | 240 | * @return {Promise<object>} A Promise that resolves when the character processing is done. |
| 209 | */ | 241 | */ |
| 210 | const processCharacter = async (item, directories) => { | 242 | const processCharacter = async (item, directories, { shallow }) => { |
| 211 | try { | 243 | try { |
| 212 | const imgFile = path.join(directories.characters, item); | 244 | const imgFile = path.join(directories.characters, item); |
| 213 | const imgData = await readCharacterData(imgFile); | 245 | const imgData = await readCharacterData(imgFile); |
| @@ -226,7 +258,7 @@ const processCharacter = async (item, directories) => { | |||
| 226 | character['chat_size'] = chatSize; | 258 | character['chat_size'] = chatSize; |
| 227 | character['date_last_chat'] = dateLastChat; | 259 | character['date_last_chat'] = dateLastChat; |
| 228 | character['data_size'] = calculateDataSize(jsonObject?.data); | 260 | character['data_size'] = calculateDataSize(jsonObject?.data); |
| 229 | return character; | 261 | return shallow ? toShallow(character) : character; |
| 230 | } | 262 | } |
| 231 | catch (err) { | 263 | catch (err) { |
| 232 | console.error(`Could not process character: ${item}`); | 264 | console.error(`Could not process character: ${item}`); |
| @@ -993,7 +1025,7 @@ router.post('/all', jsonParser, async function (request, response) { | |||
| 993 | try { | 1025 | try { |
| 994 | const files = fs.readdirSync(request.user.directories.characters); | 1026 | const files = fs.readdirSync(request.user.directories.characters); |
| 995 | const pngFiles = files.filter(file => file.endsWith('.png')); | 1027 | const pngFiles = files.filter(file => file.endsWith('.png')); |
| 996 | const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories)); | 1028 | const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories, { shallow: useShallowCharacters })); |
| 997 | const data = (await Promise.all(processingPromises)).filter(c => c.name); | 1029 | const data = (await Promise.all(processingPromises)).filter(c => c.name); |
| 998 | return response.send(data); | 1030 | return response.send(data); |
| 999 | } catch (err) { | 1031 | } catch (err) { |
| @@ -1012,7 +1044,7 @@ router.post('/get', jsonParser, validateAvatarUrlMiddleware, async function (req | |||
| 1012 | return response.sendStatus(404); | 1044 | return response.sendStatus(404); |
| 1013 | } | 1045 | } |
| 1014 | 1046 | ||
| 1015 | const data = await processCharacter(item, request.user.directories); | 1047 | const data = await processCharacter(item, request.user.directories, { shallow: false }); |
| 1016 | 1048 | ||
| 1017 | return response.send(data); | 1049 | return response.send(data); |
| 1018 | } catch (err) { | 1050 | } catch (err) { |
| @@ -1022,11 +1054,11 @@ router.post('/get', jsonParser, validateAvatarUrlMiddleware, async function (req | |||
| 1022 | }); | 1054 | }); |
| 1023 | 1055 | ||
| 1024 | router.post('/chats', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { | 1056 | router.post('/chats', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { |
| 1025 | if (!request.body) return response.sendStatus(400); | 1057 | try { |
| 1058 | if (!request.body) return response.sendStatus(400); | ||
| 1026 | 1059 | ||
| 1027 | const characterDirectory = (request.body.avatar_url).replace('.png', ''); | 1060 | const characterDirectory = (request.body.avatar_url).replace('.png', ''); |
| 1028 | 1061 | ||
| 1029 | try { | ||
| 1030 | const chatsDirectory = path.join(request.user.directories.chats, characterDirectory); | 1062 | const chatsDirectory = path.join(request.user.directories.chats, characterDirectory); |
| 1031 | 1063 | ||
| 1032 | if (!fs.existsSync(chatsDirectory)) { | 1064 | if (!fs.existsSync(chatsDirectory)) { |
| @@ -16,6 +16,7 @@ import mime from 'mime-types'; | |||
| 16 | import { default as simpleGit } from 'simple-git'; | 16 | import { default as simpleGit } from 'simple-git'; |
| 17 | import chalk from 'chalk'; | 17 | import chalk from 'chalk'; |
| 18 | import { LOG_LEVELS } from './constants.js'; | 18 | import { LOG_LEVELS } from './constants.js'; |
| 19 | import bytes from 'bytes'; | ||
| 19 | 20 | ||
| 20 | /** | 21 | /** |
| 21 | * Parsed config object. | 22 | * Parsed config object. |
| @@ -856,14 +857,10 @@ export function setupLogLevel() { | |||
| 856 | export class MemoryLimitedMap { | 857 | export class MemoryLimitedMap { |
| 857 | /** | 858 | /** |
| 858 | * Creates an instance of MemoryLimitedMap. | 859 | * Creates an instance of MemoryLimitedMap. |
| 859 | * @param {number} maxMemoryInBytes - The maximum allowed memory in bytes for string values. | 860 | * @param {string} cacheCapacity - Maximum memory usage in human-readable format (e.g., '1 GB'). |
| 860 | */ | 861 | */ |
| 861 | constructor(maxMemoryInBytes) { | 862 | constructor(cacheCapacity) { |
| 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; | ||
| 867 | this.currentMemory = 0; | 864 | this.currentMemory = 0; |
| 868 | this.map = new Map(); | 865 | this.map = new Map(); |
| 869 | this.queue = []; | 866 | this.queue = []; |
| @@ -886,6 +883,10 @@ export class MemoryLimitedMap { | |||
| 886 | * @param {string} value | 883 | * @param {string} value |
| 887 | */ | 884 | */ |
| 888 | set(key, value) { | 885 | set(key, value) { |
| 886 | if (this.maxMemory <= 0) { | ||
| 887 | return; | ||
| 888 | } | ||
| 889 | |||
| 889 | if (typeof key !== 'string' || typeof value !== 'string') { | 890 | if (typeof key !== 'string' || typeof value !== 'string') { |
| 890 | return; | 891 | return; |
| 891 | } | 892 | } |