import path from 'node:path'; import fs from 'node:fs'; import { promises as fsPromises } from 'node:fs'; import { Buffer } from 'node:buffer'; import express from 'express'; import sanitize from 'sanitize-filename'; import { sync as writeFileAtomicSync } from 'write-file-atomic'; import yaml from 'yaml'; import _ from 'lodash'; import mime from 'mime-types'; import { Jimp, JimpMime } from '../jimp.js'; import storage from 'node-persist'; import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js'; import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js'; import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString } from '../util.js'; import { TavernCardValidator } from '../validator/TavernCardValidator.js'; import { parse, read, write } from '../character-card-parser.js'; import { readWorldInfoFile } from './worldinfo.js'; import { invalidateThumbnail } from './thumbnails.js'; import { importRisuSprites } from './sprites.js'; import { getUserDirectories } from '../users.js'; import { getChatInfo } from './chats.js'; // With 100 MB limit it would take roughly 3000 characters to reach this limit const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb'); const memoryCache = new MemoryLimitedMap(memoryCacheCapacity); // Some Android devices require tighter memory management const isAndroid = process.platform === 'android'; // Use shallow character data for the character list const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean'); const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean'); class DiskCache { /** * @type {string} * @readonly */ static DIRECTORY = 'characters'; /** * @type {number} * @readonly */ static SYNC_INTERVAL = 5 * 60 * 1000; /** @type {import('node-persist').LocalStorage} */ #instance; /** @type {NodeJS.Timeout} */ #syncInterval; /** * Queue of user handles to sync. * @type {Set} * @readonly */ syncQueue = new Set(); /** * Path to the cache directory. * @returns {string} */ get cachePath() { return path.join(globalThis.DATA_ROOT, '_cache', DiskCache.DIRECTORY); } /** * Returns the list of hashed keys in the cache. * @returns {string[]} */ get hashedKeys() { return fs.readdirSync(this.cachePath); } /** * Processes the synchronization queue. * @returns {Promise} */ async #syncCacheEntries() { try { if (!useDiskCache || this.syncQueue.size === 0) { return; } const directories = [...this.syncQueue].map(entry => getUserDirectories(entry)); this.syncQueue.clear(); await this.verify(directories); } catch (error) { console.error('Error while synchronizing cache entries:', error); } } /** * Gets the disk cache instance. * @returns {Promise} */ async instance() { if (this.#instance) { return this.#instance; } this.#instance = storage.create({ dir: this.cachePath, ttl: false, forgiveParseErrors: true, // @ts-ignore maxFileDescriptors: 100, }); await this.#instance.init(); this.#syncInterval = setInterval(this.#syncCacheEntries.bind(this), DiskCache.SYNC_INTERVAL); return this.#instance; } /** * Verifies disk cache size and prunes it if necessary. * @param {import('../users.js').UserDirectoryList[]} directoriesList List of user directories * @returns {Promise} */ async verify(directoriesList) { try { if (!useDiskCache) { return; } const cache = await this.instance(); const validKeys = new Set(); for (const dir of directoriesList) { const files = fs.readdirSync(dir.characters, { withFileTypes: true }); for (const file of files.filter(f => f.isFile() && path.extname(f.name) === '.png')) { const filePath = path.join(dir.characters, file.name); const cacheKey = getCacheKey(filePath); validKeys.add(path.parse(cache.getDatumPath(cacheKey)).base); } } for (const key of this.hashedKeys) { if (!validKeys.has(key)) { await cache.removeItem(key); } } } catch (error) { console.error('Error while verifying disk cache:', error); } } dispose() { if (this.#syncInterval) { clearInterval(this.#syncInterval); } } } export const diskCache = new DiskCache(); /** * Gets the cache key for the specified image file. * @param {string} inputFile - Path to the image file * @returns {string} - Cache key */ function getCacheKey(inputFile) { if (fs.existsSync(inputFile)) { const stat = fs.statSync(inputFile); return `${inputFile}-${stat.mtimeMs}`; } return inputFile; } /** * Reads the character card from the specified image file. * @param {string} inputFile - Path to the image file * @param {string} inputFormat - 'png' * @returns {Promise} - Character card data */ async function readCharacterData(inputFile, inputFormat = 'png') { const cacheKey = getCacheKey(inputFile); if (memoryCache.has(cacheKey)) { return memoryCache.get(cacheKey); } if (useDiskCache) { try { const cache = await diskCache.instance(); const cachedData = await cache.getItem(cacheKey); if (cachedData) { return cachedData; } } catch (error) { console.warn('Error while reading from disk cache:', error); } } const result = await parse(inputFile, inputFormat); !isAndroid && memoryCache.set(cacheKey, result); if (useDiskCache) { try { const cache = await diskCache.instance(); await cache.setItem(cacheKey, result); } catch (error) { console.warn('Error while writing to disk cache:', error); } } return result; } /** * Writes the character card to the specified image file. * @param {string|Buffer} inputFile - Path to the image file or image buffer * @param {string} data - Character card data * @param {string} outputFile - Target image file name * @param {import('express').Request} request - Express request obejct * @param {Crop|undefined} crop - Crop parameters * @returns {Promise} - True if the operation was successful */ async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) { try { // Reset the cache for (const key of memoryCache.keys()) { if (Buffer.isBuffer(inputFile)) { break; } if (key.startsWith(inputFile)) { memoryCache.delete(key); break; } } if (useDiskCache && !Buffer.isBuffer(inputFile)) { diskCache.syncQueue.add(request.user.profile.handle); } /** * Read the image, resize, and save it as a PNG into the buffer. * @returns {Promise} Image buffer */ async function getInputImage() { try { if (Buffer.isBuffer(inputFile)) { return await parseImageBuffer(inputFile, crop); } return await tryReadImage(inputFile, crop); } catch (error) { const message = Buffer.isBuffer(inputFile) ? 'Failed to read image buffer.' : `Failed to read image: ${inputFile}.`; console.warn(message, 'Using a fallback image.', error); return await fs.promises.readFile(DEFAULT_AVATAR_PATH); } } const inputImage = await getInputImage(); // Get the chunks const outputImage = write(inputImage, data); const outputImagePath = path.join(request.user.directories.characters, `${outputFile}.png`); writeFileAtomicSync(outputImagePath, outputImage); return true; } catch (err) { console.error(err); return false; } } /** * @typedef {Object} Crop * @property {number} x X-coordinate * @property {number} y Y-coordinate * @property {number} width Width * @property {number} height Height * @property {boolean} want_resize Resize the image to the standard avatar size */ /** * Parses an image buffer and applies crop if defined. * @param {Buffer} buffer Buffer of the image * @param {Crop|undefined} [crop] Crop parameters * @returns {Promise} Image buffer */ async function parseImageBuffer(buffer, crop) { const image = await Jimp.fromBuffer(buffer); let finalWidth = image.bitmap.width, finalHeight = image.bitmap.height; // Apply crop if defined if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) { image.crop({ x: crop.x, y: crop.y, w: crop.width, h: crop.height }); // Apply standard resize if requested if (crop.want_resize) { finalWidth = AVATAR_WIDTH; finalHeight = AVATAR_HEIGHT; } else { finalWidth = crop.width; finalHeight = crop.height; } } image.cover({ w: finalWidth, h: finalHeight }); return await image.getBuffer(JimpMime.png); } /** * Reads an image file and applies crop if defined. * @param {string} imgPath Path to the image file * @param {Crop|undefined} crop Crop parameters * @returns {Promise} Image buffer */ async function tryReadImage(imgPath, crop) { try { const rawImg = await Jimp.read(imgPath); let finalWidth = rawImg.bitmap.width, finalHeight = rawImg.bitmap.height; // Apply crop if defined if (typeof crop == 'object' && [crop.x, crop.y, crop.width, crop.height].every(x => typeof x === 'number')) { rawImg.crop({ x: crop.x, y: crop.y, w: crop.width, h: crop.height }); // Apply standard resize if requested if (crop.want_resize) { finalWidth = AVATAR_WIDTH; finalHeight = AVATAR_HEIGHT; } else { finalWidth = crop.width; finalHeight = crop.height; } } rawImg.cover({ w: finalWidth, h: finalHeight }); return await rawImg.getBuffer(JimpMime.png); } // If it's an unsupported type of image (APNG) - just read the file as buffer catch (error) { console.error(`Failed to read image: ${imgPath}`, error); return fs.readFileSync(imgPath); } } /** * calculateChatSize - Calculates the total chat size for a given character. * * @param {string} charDir The directory where the chats are stored. * @return { {chatSize: number, dateLastChat: number} } The total chat size. */ const calculateChatSize = (charDir) => { let chatSize = 0; let dateLastChat = 0; if (fs.existsSync(charDir)) { const chats = fs.readdirSync(charDir); if (Array.isArray(chats) && chats.length) { for (const chat of chats) { const chatStat = fs.statSync(path.join(charDir, chat)); chatSize += chatStat.size; dateLastChat = Math.max(dateLastChat, chatStat.mtimeMs); } } } return { chatSize, dateLastChat }; }; // Calculate the total string length of the data object const calculateDataSize = (data) => { return typeof data === 'object' ? Object.values(data).reduce((acc, val) => acc + String(val).length, 0) : 0; }; /** * Only get fields that are used to display the character list. * @param {object} character Character object * @returns {{shallow: true, [key: string]: any}} Shallow character */ const toShallow = (character) => { return { shallow: true, name: character.name, avatar: character.avatar, chat: character.chat, fav: character.fav, date_added: character.date_added, create_date: character.create_date, date_last_chat: character.date_last_chat, chat_size: character.chat_size, data_size: character.data_size, tags: character.tags, data: { name: _.get(character, 'data.name', ''), character_version: _.get(character, 'data.character_version', ''), creator: _.get(character, 'data.creator', ''), creator_notes: _.get(character, 'data.creator_notes', ''), tags: _.get(character, 'data.tags', []), extensions: { fav: _.get(character, 'data.extensions.fav', false), }, }, }; }; /** * processCharacter - Process a given character, read its data and calculate its statistics. * * @param {string} item The name of the character. * @param {import('../users.js').UserDirectoryList} directories User directories * @param {object} options Options for the character processing * @param {boolean} options.shallow If true, only return the core character's metadata * @return {Promise} A Promise that resolves when the character processing is done. */ const processCharacter = async (item, directories, { shallow }) => { try { const imgFile = path.join(directories.characters, item); const imgData = await readCharacterData(imgFile); if (imgData === undefined) throw new Error('Failed to read character file'); let jsonObject = getCharaCardV2(JSON.parse(imgData), directories, false); jsonObject.avatar = item; const character = jsonObject; character['json_data'] = imgData; const charStat = fs.statSync(path.join(directories.characters, item)); character['date_added'] = charStat.ctimeMs; character['create_date'] = jsonObject['create_date'] || humanizedISO8601DateTime(charStat.ctimeMs); const chatsDirectory = path.join(directories.chats, item.replace('.png', '')); const { chatSize, dateLastChat } = calculateChatSize(chatsDirectory); character['chat_size'] = chatSize; character['date_last_chat'] = dateLastChat; character['data_size'] = calculateDataSize(jsonObject?.data); return shallow ? toShallow(character) : character; } catch (err) { console.error(`Could not process character: ${item}`); if (err instanceof SyntaxError) { console.error(`${item} does not contain a valid JSON object.`); } else { console.error('An unexpected error occurred: ', err); } return { date_added: 0, date_last_chat: 0, chat_size: 0, }; } }; /** * Convert a character object to Spec V2 format. * @param {object} jsonObject Character object * @param {import('../users.js').UserDirectoryList} directories User directories * @param {boolean} hoistDate Will set the chat and create_date fields to the current date if they are missing * @returns {object} Character object in Spec V2 format */ function getCharaCardV2(jsonObject, directories, hoistDate = true) { if (jsonObject.spec === undefined) { jsonObject = convertToV2(jsonObject, directories); if (hoistDate && !jsonObject.create_date) { jsonObject.create_date = humanizedISO8601DateTime(); } } else { jsonObject = readFromV2(jsonObject); } return jsonObject; } /** * Convert a character object to Spec V2 format. * @param {object} char Character object * @param {import('../users.js').UserDirectoryList} directories User directories * @returns {object} Character object in Spec V2 format */ function convertToV2(char, directories) { // Simulate incoming data from frontend form const result = charaFormatData({ json_data: JSON.stringify(char), ch_name: char.name, description: char.description, personality: char.personality, scenario: char.scenario, first_mes: char.first_mes, mes_example: char.mes_example, creator_notes: char.creatorcomment, talkativeness: char.talkativeness, fav: char.fav, creator: char.creator, tags: char.tags, depth_prompt_prompt: char.depth_prompt_prompt, depth_prompt_depth: char.depth_prompt_depth, depth_prompt_role: char.depth_prompt_role, }, directories); result.chat = char.chat ?? humanizedISO8601DateTime(); result.create_date = char.create_date; return result; } /** * Removes fields that are not meant to be shared. */ function unsetPrivateFields(char) { _.set(char, 'fav', false); _.set(char, 'data.extensions.fav', false); _.unset(char, 'chat'); } function readFromV2(char) { if (_.isUndefined(char.data)) { console.warn(`Char ${char['name']} has Spec v2 data missing`); return char; } const fieldMappings = { name: 'name', description: 'description', personality: 'personality', scenario: 'scenario', first_mes: 'first_mes', mes_example: 'mes_example', talkativeness: 'extensions.talkativeness', fav: 'extensions.fav', tags: 'tags', }; _.forEach(fieldMappings, (v2Path, charField) => { //console.info(`Migrating field: ${charField} from ${v2Path}`); const v2Value = _.get(char.data, v2Path); if (_.isUndefined(v2Value)) { let defaultValue = undefined; // Backfill default values for missing ST extension fields if (v2Path === 'extensions.talkativeness') { defaultValue = 0.5; } if (v2Path === 'extensions.fav') { defaultValue = false; } if (!_.isUndefined(defaultValue)) { //console.warn(`Spec v2 extension data missing for field: ${charField}, using default value: ${defaultValue}`); char[charField] = defaultValue; } else { console.warn(`Char ${char['name']} has Spec v2 data missing for unknown field: ${charField}`); return; } } if (!_.isUndefined(char[charField]) && !_.isUndefined(v2Value) && String(char[charField]) !== String(v2Value)) { console.warn(`Char ${char['name']} has Spec v2 data mismatch with Spec v1 for field: ${charField}`, char[charField], v2Value); } char[charField] = v2Value; }); char['chat'] = char['chat'] ?? humanizedISO8601DateTime(); return char; } /** * Format character data to Spec V2 format. * @param {object} data Character data * @param {import('../users.js').UserDirectoryList} directories User directories * @returns */ function charaFormatData(data, directories) { // This is supposed to save all the foreign keys that ST doesn't care about const char = tryParse(data.json_data) || {}; // Checks if data.alternate_greetings is an array, a string, or neither, and acts accordingly. (expected to be an array of strings) const getAlternateGreetings = data => { if (Array.isArray(data.alternate_greetings)) return data.alternate_greetings; if (typeof data.alternate_greetings === 'string') return [data.alternate_greetings]; return []; }; // Spec V1 fields _.set(char, 'name', data.ch_name); _.set(char, 'description', data.description || ''); _.set(char, 'personality', data.personality || ''); _.set(char, 'scenario', data.scenario || ''); _.set(char, 'first_mes', data.first_mes || ''); _.set(char, 'mes_example', data.mes_example || ''); // Old ST extension fields (for backward compatibility, will be deprecated) _.set(char, 'creatorcomment', data.creator_notes || ''); _.set(char, 'avatar', 'none'); _.set(char, 'chat', data.ch_name + ' - ' + humanizedISO8601DateTime()); _.set(char, 'talkativeness', data.talkativeness || 0.5); _.set(char, 'fav', data.fav == 'true'); _.set(char, 'tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []); // Spec V2 fields _.set(char, 'spec', 'chara_card_v2'); _.set(char, 'spec_version', '2.0'); _.set(char, 'data.name', data.ch_name); _.set(char, 'data.description', data.description || ''); _.set(char, 'data.personality', data.personality || ''); _.set(char, 'data.scenario', data.scenario || ''); _.set(char, 'data.first_mes', data.first_mes || ''); _.set(char, 'data.mes_example', data.mes_example || ''); // New V2 fields _.set(char, 'data.creator_notes', data.creator_notes || ''); _.set(char, 'data.system_prompt', data.system_prompt || ''); _.set(char, 'data.post_history_instructions', data.post_history_instructions || ''); _.set(char, 'data.tags', typeof data.tags == 'string' ? (data.tags.split(',').map(x => x.trim()).filter(x => x)) : data.tags || []); _.set(char, 'data.creator', data.creator || ''); _.set(char, 'data.character_version', data.character_version || ''); _.set(char, 'data.alternate_greetings', getAlternateGreetings(data)); // ST extension fields to V2 object _.set(char, 'data.extensions.talkativeness', data.talkativeness || 0.5); _.set(char, 'data.extensions.fav', data.fav == 'true'); _.set(char, 'data.extensions.world', data.world || ''); // Spec extension: depth prompt const depth_default = 4; const role_default = 'system'; const depth_value = !isNaN(Number(data.depth_prompt_depth)) ? Number(data.depth_prompt_depth) : depth_default; const role_value = data.depth_prompt_role ?? role_default; _.set(char, 'data.extensions.depth_prompt.prompt', data.depth_prompt_prompt ?? ''); _.set(char, 'data.extensions.depth_prompt.depth', depth_value); _.set(char, 'data.extensions.depth_prompt.role', role_value); //_.set(char, 'data.extensions.create_date', humanizedISO8601DateTime()); //_.set(char, 'data.extensions.avatar', 'none'); //_.set(char, 'data.extensions.chat', data.ch_name + ' - ' + humanizedISO8601DateTime()); // V3 fields _.set(char, 'data.group_only_greetings', data.group_only_greetings ?? []); if (data.world) { try { const file = readWorldInfoFile(directories, data.world, false); // File was imported - save it to the character book if (file && file.originalData) { _.set(char, 'data.character_book', file.originalData); } // File was not imported - convert the world info to the character book if (file && file.entries) { _.set(char, 'data.character_book', convertWorldInfoToCharacterBook(data.world, file.entries)); } } catch { console.warn(`Failed to read world info file: ${data.world}. Character book will not be available.`); } } if (data.extensions) { try { const extensions = JSON.parse(data.extensions); // Deep merge the extensions object _.set(char, 'data.extensions', deepMerge(char.data.extensions, extensions)); } catch { console.warn(`Failed to parse extensions JSON: ${data.extensions}`); } } return char; } /** * @param {string} name Name of World Info file * @param {object} entries Entries object */ function convertWorldInfoToCharacterBook(name, entries) { /** @type {{ entries: object[]; name: string }} */ const result = { entries: [], name }; for (const index in entries) { const entry = entries[index]; const originalEntry = { id: entry.uid, keys: entry.key, secondary_keys: entry.keysecondary, comment: entry.comment, content: entry.content, constant: entry.constant, selective: entry.selective, insertion_order: entry.order, enabled: !entry.disable, position: entry.position == 0 ? 'before_char' : 'after_char', use_regex: true, // ST keys are always regex extensions: { ...entry.extensions, position: entry.position, exclude_recursion: entry.excludeRecursion, display_index: entry.displayIndex, probability: entry.probability ?? null, useProbability: entry.useProbability ?? false, depth: entry.depth ?? 4, selectiveLogic: entry.selectiveLogic ?? 0, group: entry.group ?? '', group_override: entry.groupOverride ?? false, group_weight: entry.groupWeight ?? null, prevent_recursion: entry.preventRecursion ?? false, delay_until_recursion: entry.delayUntilRecursion ?? false, scan_depth: entry.scanDepth ?? null, match_whole_words: entry.matchWholeWords ?? null, use_group_scoring: entry.useGroupScoring ?? false, case_sensitive: entry.caseSensitive ?? null, automation_id: entry.automationId ?? '', role: entry.role ?? 0, vectorized: entry.vectorized ?? false, sticky: entry.sticky ?? null, cooldown: entry.cooldown ?? null, delay: entry.delay ?? null, match_persona_description: entry.matchPersonaDescription ?? false, match_character_description: entry.matchCharacterDescription ?? false, match_character_personality: entry.matchCharacterPersonality ?? false, match_character_depth_prompt: entry.matchCharacterDepthPrompt ?? false, match_scenario: entry.matchScenario ?? false, match_creator_notes: entry.matchCreatorNotes ?? false, }, }; result.entries.push(originalEntry); } return result; } /** * Import a character from a YAML file. * @param {string} uploadPath Path to the uploaded file * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects * @param {string|undefined} preservedFileName Preserved file name * @returns {Promise} Internal name of the character */ async function importFromYaml(uploadPath, context, preservedFileName) { const fileText = fs.readFileSync(uploadPath, 'utf8'); fs.unlinkSync(uploadPath); const yamlData = yaml.parse(fileText); console.info('Importing from YAML'); yamlData.name = sanitize(yamlData.name); const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories); let char = convertToV2({ 'name': yamlData.name, 'description': yamlData.context ?? '', 'first_mes': yamlData.greeting ?? '', 'create_date': humanizedISO8601DateTime(), 'chat': `${yamlData.name} - ${humanizedISO8601DateTime()}`, 'personality': '', 'creatorcomment': '', 'avatar': 'none', 'mes_example': '', 'scenario': '', 'talkativeness': 0.5, 'creator': '', 'tags': '', }, context.request.user.directories); const result = await writeCharacterData(DEFAULT_AVATAR_PATH, JSON.stringify(char), fileName, context.request); return result ? fileName : ''; } /** * Imports a character card from CharX (ZIP) file. * @param {string} uploadPath * @param {object} params * @param {import('express').Request} params.request * @param {string|undefined} preservedFileName Preserved file name * @returns {Promise} Internal name of the character */ async function importFromCharX(uploadPath, { request }, preservedFileName) { const data = fs.readFileSync(uploadPath).buffer; fs.unlinkSync(uploadPath); console.info('Importing from CharX'); const cardBuffer = await extractFileFromZipBuffer(data, 'card.json'); if (!cardBuffer) { throw new Error('Failed to extract card.json from CharX file'); } const card = readFromV2(JSON.parse(cardBuffer.toString())); if (card.spec === undefined) { throw new Error('Invalid CharX card file: missing spec field'); } /** @type {string|Buffer} */ let avatar = DEFAULT_AVATAR_PATH; const assets = _.get(card, 'data.assets'); if (Array.isArray(assets) && assets.length) { for (const asset of assets.filter(x => x.type === 'icon' && typeof x.uri === 'string')) { const pathNoProtocol = String(asset.uri.replace(/^(?:\/\/|[^/]+)*\//, '')); const buffer = await extractFileFromZipBuffer(data, pathNoProtocol); if (buffer) { avatar = buffer; break; } } } unsetPrivateFields(card); card['create_date'] = humanizedISO8601DateTime(); card.name = sanitize(card.name); const fileName = preservedFileName || getPngName(card.name, request.user.directories); const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request); return result ? fileName : ''; } /** * Import a character from a JSON file. * @param {string} uploadPath Path to the uploaded file * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects * @param {string|undefined} preservedFileName Preserved file name * @returns {Promise} Internal name of the character */ async function importFromJson(uploadPath, { request }, preservedFileName) { const data = fs.readFileSync(uploadPath, 'utf8'); fs.unlinkSync(uploadPath); let jsonData = JSON.parse(data); if (jsonData.spec !== undefined) { console.info(`Importing from ${jsonData.spec} json`); importRisuSprites(request.user.directories, jsonData); unsetPrivateFields(jsonData); jsonData = readFromV2(jsonData); jsonData['create_date'] = humanizedISO8601DateTime(); const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories); const char = JSON.stringify(jsonData); const result = await writeCharacterData(DEFAULT_AVATAR_PATH, char, pngName, request); return result ? pngName : ''; } else if (jsonData.name !== undefined) { console.info('Importing from v1 json'); jsonData.name = sanitize(jsonData.name); if (jsonData.creator_notes) { jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); } const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); let char = { 'name': jsonData.name, 'description': jsonData.description ?? '', 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', 'personality': jsonData.personality ?? '', 'first_mes': jsonData.first_mes ?? '', 'avatar': 'none', 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), 'mes_example': jsonData.mes_example ?? '', 'scenario': jsonData.scenario ?? '', 'create_date': humanizedISO8601DateTime(), 'talkativeness': jsonData.talkativeness ?? 0.5, 'creator': jsonData.creator ?? '', 'tags': jsonData.tags ?? '', }; char = convertToV2(char, request.user.directories); let charJSON = JSON.stringify(char); const result = await writeCharacterData(DEFAULT_AVATAR_PATH, charJSON, pngName, request); return result ? pngName : ''; } else if (jsonData.char_name !== undefined) {//json Pygmalion notepad console.info('Importing from gradio json'); jsonData.char_name = sanitize(jsonData.char_name); if (jsonData.creator_notes) { jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); } const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories); let char = { 'name': jsonData.char_name, 'description': jsonData.char_persona ?? '', 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', 'personality': '', 'first_mes': jsonData.char_greeting ?? '', 'avatar': 'none', 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), 'mes_example': jsonData.example_dialogue ?? '', 'scenario': jsonData.world_scenario ?? '', 'create_date': humanizedISO8601DateTime(), 'talkativeness': jsonData.talkativeness ?? 0.5, 'creator': jsonData.creator ?? '', 'tags': jsonData.tags ?? '', }; char = convertToV2(char, request.user.directories); const charJSON = JSON.stringify(char); const result = await writeCharacterData(DEFAULT_AVATAR_PATH, charJSON, pngName, request); return result ? pngName : ''; } return ''; } /** * Import a character from a PNG file. * @param {string} uploadPath Path to the uploaded file * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects * @param {string|undefined} preservedFileName Preserved file name * @returns {Promise} Internal name of the character */ async function importFromPng(uploadPath, { request }, preservedFileName) { const imgData = await readCharacterData(uploadPath); if (imgData === undefined) throw new Error('Failed to read character data'); let jsonData = JSON.parse(imgData); jsonData.name = sanitize(jsonData.data?.name || jsonData.name); const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories); if (jsonData.spec !== undefined) { console.info(`Found a ${jsonData.spec} character file.`); importRisuSprites(request.user.directories, jsonData); unsetPrivateFields(jsonData); jsonData = readFromV2(jsonData); jsonData['create_date'] = humanizedISO8601DateTime(); const char = JSON.stringify(jsonData); const result = await writeCharacterData(uploadPath, char, pngName, request); fs.unlinkSync(uploadPath); return result ? pngName : ''; } else if (jsonData.name !== undefined) { console.info('Found a v1 character file.'); if (jsonData.creator_notes) { jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', ''); } let char = { 'name': jsonData.name, 'description': jsonData.description ?? '', 'creatorcomment': jsonData.creatorcomment ?? jsonData.creator_notes ?? '', 'personality': jsonData.personality ?? '', 'first_mes': jsonData.first_mes ?? '', 'avatar': 'none', 'chat': jsonData.name + ' - ' + humanizedISO8601DateTime(), 'mes_example': jsonData.mes_example ?? '', 'scenario': jsonData.scenario ?? '', 'create_date': humanizedISO8601DateTime(), 'talkativeness': jsonData.talkativeness ?? 0.5, 'creator': jsonData.creator ?? '', 'tags': jsonData.tags ?? '', }; char = convertToV2(char, request.user.directories); const charJSON = JSON.stringify(char); const result = await writeCharacterData(uploadPath, charJSON, pngName, request); fs.unlinkSync(uploadPath); return result ? pngName : ''; } return ''; } export const router = express.Router(); router.post('/create', getFileNameValidationFunction('file_name'), async function (request, response) { try { if (!request.body) return response.sendStatus(400); request.body.ch_name = sanitize(request.body.ch_name); const char = JSON.stringify(charaFormatData(request.body, request.user.directories)); const internalName = request.body.file_name || getPngName(request.body.ch_name, request.user.directories); const avatarName = `${internalName}.png`; const chatsPath = path.join(request.user.directories.chats, internalName); if (!fs.existsSync(chatsPath)) fs.mkdirSync(chatsPath); if (!request.file) { await writeCharacterData(DEFAULT_AVATAR_PATH, char, internalName, request); return response.send(avatarName); } else { const crop = tryParse(request.query.crop); const uploadPath = path.join(request.file.destination, request.file.filename); await writeCharacterData(uploadPath, char, internalName, request, crop); fs.unlinkSync(uploadPath); return response.send(avatarName); } } catch (err) { console.error(err); response.sendStatus(500); } }); router.post('/rename', validateAvatarUrlMiddleware, async function (request, response) { if (!request.body.avatar_url || !request.body.new_name) { return response.sendStatus(400); } const oldAvatarName = request.body.avatar_url; const newName = sanitize(request.body.new_name); const oldInternalName = path.parse(request.body.avatar_url).name; const newInternalName = getPngName(newName, request.user.directories); const newAvatarName = `${newInternalName}.png`; const oldAvatarPath = path.join(request.user.directories.characters, oldAvatarName); const oldChatsPath = path.join(request.user.directories.chats, oldInternalName); const newChatsPath = path.join(request.user.directories.chats, newInternalName); try { // Read old file, replace name int it const rawOldData = await readCharacterData(oldAvatarPath); if (rawOldData === undefined) throw new Error('Failed to read character file'); const oldData = getCharaCardV2(JSON.parse(rawOldData), request.user.directories); _.set(oldData, 'data.name', newName); _.set(oldData, 'name', newName); const newData = JSON.stringify(oldData); // Write data to new location await writeCharacterData(oldAvatarPath, newData, newInternalName, request); // Rename chats folder if (fs.existsSync(oldChatsPath) && !fs.existsSync(newChatsPath)) { fs.cpSync(oldChatsPath, newChatsPath, { recursive: true }); fs.rmSync(oldChatsPath, { recursive: true, force: true }); } // Remove the old character file fs.unlinkSync(oldAvatarPath); // Return new avatar name to ST return response.send({ avatar: newAvatarName }); } catch (err) { console.error(err); return response.sendStatus(500); } }); router.post('/edit', validateAvatarUrlMiddleware, async function (request, response) { if (!request.body) { console.warn('Error: no response body detected'); response.status(400).send('Error: no response body detected'); return; } if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') { console.warn('Error: invalid name.'); response.status(400).send('Error: invalid name.'); return; } let char = charaFormatData(request.body, request.user.directories); char.chat = request.body.chat; char.create_date = request.body.create_date; char = JSON.stringify(char); let targetFile = (request.body.avatar_url).replace('.png', ''); try { if (!request.file) { const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url); await writeCharacterData(avatarPath, char, targetFile, request); } else { const crop = tryParse(request.query.crop); const newAvatarPath = path.join(request.file.destination, request.file.filename); invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url); await writeCharacterData(newAvatarPath, char, targetFile, request, crop); fs.unlinkSync(newAvatarPath); // Bust cache to reload the new avatar response.setHeader('Clear-Site-Data', '"cache"'); } return response.sendStatus(200); } catch (err) { console.error('An error occurred, character edit invalidated.', err); } }); /** * Handle a POST request to edit a character attribute. * * This function reads the character data from a file, updates the specified attribute, * and writes the updated data back to the file. * * @param {Object} request - The HTTP request object. * @param {Object} response - The HTTP response object. * @returns {void} */ router.post('/edit-attribute', validateAvatarUrlMiddleware, async function (request, response) { console.debug(request.body); if (!request.body) { console.warn('Error: no response body detected'); return response.status(400).send('Error: no response body detected'); } if (request.body.ch_name === '' || request.body.ch_name === undefined || request.body.ch_name === '.') { console.warn('Error: invalid name.'); return response.status(400).send('Error: invalid name.'); } try { const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url); const charJSON = await readCharacterData(avatarPath); if (typeof charJSON !== 'string') throw new Error('Failed to read character file'); const char = JSON.parse(charJSON); //check if the field exists if (char[request.body.field] === undefined && char.data[request.body.field] === undefined) { console.warn('Error: invalid field.'); response.status(400).send('Error: invalid field.'); return; } char[request.body.field] = request.body.value; char.data[request.body.field] = request.body.value; let newCharJSON = JSON.stringify(char); const targetFile = (request.body.avatar_url).replace('.png', ''); await writeCharacterData(avatarPath, newCharJSON, targetFile, request); return response.sendStatus(200); } catch (err) { console.error('An error occurred, character edit invalidated.', err); } }); /** * Handle a POST request to edit character properties. * * Merges the request body with the selected character and * validates the result against TavernCard V2 specification. * * @param {Object} request - The HTTP request object. * @param {Object} response - The HTTP response object. * * @returns {void} * */ router.post('/merge-attributes', getFileNameValidationFunction('avatar'), async function (request, response) { try { const update = request.body; const avatarPath = path.join(request.user.directories.characters, update.avatar); const pngStringData = await readCharacterData(avatarPath); if (!pngStringData) { console.error('Error: invalid character file.'); return response.status(400).send('Error: invalid character file.'); } let character = JSON.parse(pngStringData); character = deepMerge(character, update); const validator = new TavernCardValidator(character); const targetImg = (update.avatar).replace('.png', ''); //Accept either V1 or V2. if (validator.validate()) { await writeCharacterData(avatarPath, JSON.stringify(character), targetImg, request); response.sendStatus(200); } else { console.warn(validator.lastValidationError); response.status(400).send({ message: `Validation failed for ${character.name}`, error: validator.lastValidationError }); } } catch (exception) { response.status(500).send({ message: 'Unexpected error while saving character.', error: exception.toString() }); } }); router.post('/delete', validateAvatarUrlMiddleware, async function (request, response) { if (!request.body || !request.body.avatar_url) { return response.sendStatus(400); } if (request.body.avatar_url !== sanitize(request.body.avatar_url)) { console.error('Malicious filename prevented'); return response.sendStatus(403); } const avatarPath = path.join(request.user.directories.characters, request.body.avatar_url); if (!fs.existsSync(avatarPath)) { return response.sendStatus(400); } fs.unlinkSync(avatarPath); invalidateThumbnail(request.user.directories, 'avatar', request.body.avatar_url); let dir_name = (request.body.avatar_url.replace('.png', '')); if (!dir_name.length) { console.error('Malicious dirname prevented'); return response.sendStatus(403); } if (request.body.delete_chats == true) { try { await fs.promises.rm(path.join(request.user.directories.chats, sanitize(dir_name)), { recursive: true, force: true }); } catch (err) { console.error(err); return response.sendStatus(500); } } return response.sendStatus(200); }); /** * HTTP POST endpoint for the "/api/characters/all" route. * * This endpoint is responsible for reading character files from the `charactersPath` directory, * parsing character data, calculating stats for each character and responding with the data. * Stats are calculated only on the first run, on subsequent runs the stats are fetched from * the `charStats` variable. * The stats are calculated by the `calculateStats` function. * The characters are processed by the `processCharacter` function. * * @param {import("express").Request} request The HTTP request object. * @param {import("express").Response} response The HTTP response object. * @return {void} */ router.post('/all', async function (request, response) { try { const files = fs.readdirSync(request.user.directories.characters); const pngFiles = files.filter(file => file.endsWith('.png')); const processingPromises = pngFiles.map(file => processCharacter(file, request.user.directories, { shallow: useShallowCharacters })); const data = (await Promise.all(processingPromises)).filter(c => c.name); return response.send(data); } catch (err) { console.error(err); response.sendStatus(500); } }); router.post('/get', validateAvatarUrlMiddleware, async function (request, response) { try { if (!request.body) return response.sendStatus(400); const item = request.body.avatar_url; const filePath = path.join(request.user.directories.characters, item); if (!fs.existsSync(filePath)) { return response.sendStatus(404); } const data = await processCharacter(item, request.user.directories, { shallow: false }); return response.send(data); } catch (err) { console.error(err); response.sendStatus(500); } }); router.post('/chats', validateAvatarUrlMiddleware, async function (request, response) { try { if (!request.body) return response.sendStatus(400); const characterDirectory = (request.body.avatar_url).replace('.png', ''); const chatsDirectory = path.join(request.user.directories.chats, characterDirectory); if (!fs.existsSync(chatsDirectory)) { return response.send({ error: true }); } const files = fs.readdirSync(chatsDirectory); const jsonFiles = files.filter(file => path.extname(file) === '.jsonl'); if (jsonFiles.length === 0) { response.send({ error: true }); return; } if (request.body.simple) { return response.send(jsonFiles.map(file => ({ file_name: file }))); } const jsonFilesPromise = jsonFiles.map((file) => { const pathToFile = path.join(request.user.directories.chats, characterDirectory, file); return getChatInfo(pathToFile); }); const chatData = (await Promise.allSettled(jsonFilesPromise)).filter(x => x.status === 'fulfilled').map(x => x.value); const validFiles = chatData.filter(i => i.file_name); return response.send(validFiles); } catch (error) { console.error(error); return response.send({ error: true }); } }); /** * Gets the name for the uploaded PNG file. * @param {string} file File name * @param {import('../users.js').UserDirectoryList} directories User directories * @returns {string} - The name for the uploaded PNG file */ function getPngName(file, directories) { let i = 1; const baseName = file; while (fs.existsSync(path.join(directories.characters, `${file}.png`))) { file = baseName + i; i++; } return file; } /** * Gets the preserved name for the uploaded file if the request is valid. * @param {import("express").Request} request - Express request object * @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined */ function getPreservedName(request) { return typeof request.body.preserved_name === 'string' && request.body.preserved_name.length > 0 ? path.parse(request.body.preserved_name).name : undefined; } router.post('/import', async function (request, response) { if (!request.body || !request.file) return response.sendStatus(400); const uploadPath = path.join(request.file.destination, request.file.filename); const format = request.body.file_type; const preservedFileName = getPreservedName(request); const formatImportFunctions = { 'yaml': importFromYaml, 'yml': importFromYaml, 'json': importFromJson, 'png': importFromPng, 'charx': importFromCharX, }; try { const importFunction = formatImportFunctions[format]; if (!importFunction) { throw new Error(`Unsupported format: ${format}`); } const fileName = await importFunction(uploadPath, { request, response }, preservedFileName); if (!fileName) { console.warn('Failed to import character'); return response.sendStatus(400); } if (preservedFileName) { invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`); } response.send({ file_name: fileName }); } catch (err) { console.error(err); response.send({ error: true }); } }); router.post('/duplicate', validateAvatarUrlMiddleware, async function (request, response) { try { if (!request.body.avatar_url) { console.warn('avatar URL not found in request body'); console.debug(request.body); return response.sendStatus(400); } let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url)); if (!fs.existsSync(filename)) { console.error('file for dupe not found', filename); return response.sendStatus(404); } let suffix = 1; let newFilename = filename; // If filename ends with a _number, increment the number const nameParts = path.basename(filename, path.extname(filename)).split('_'); const lastPart = nameParts[nameParts.length - 1]; let baseName; if (!isNaN(Number(lastPart)) && nameParts.length > 1) { suffix = parseInt(lastPart) + 1; baseName = nameParts.slice(0, -1).join('_'); // construct baseName without suffix } else { baseName = nameParts.join('_'); // original filename is completely the baseName } newFilename = path.join(request.user.directories.characters, `${baseName}_${suffix}${path.extname(filename)}`); while (fs.existsSync(newFilename)) { let suffixStr = '_' + suffix; newFilename = path.join(request.user.directories.characters, `${baseName}${suffixStr}${path.extname(filename)}`); suffix++; } fs.copyFileSync(filename, newFilename); console.info(`${filename} was copied to ${newFilename}`); response.send({ path: path.parse(newFilename).base }); } catch (error) { console.error(error); return response.send({ error: true }); } }); router.post('/export', validateAvatarUrlMiddleware, async function (request, response) { try { if (!request.body.format || !request.body.avatar_url) { return response.sendStatus(400); } let filename = path.join(request.user.directories.characters, sanitize(request.body.avatar_url)); if (!fs.existsSync(filename)) { return response.sendStatus(404); } switch (request.body.format) { case 'png': { const rawBuffer = await fsPromises.readFile(filename); const rawData = read(rawBuffer); const mutatedData = mutateJsonString(rawData, unsetPrivateFields); const mutatedBuffer = write(rawBuffer, mutatedData); const contentType = mime.lookup(filename) || 'image/png'; response.setHeader('Content-Type', contentType); response.setHeader('Content-Disposition', `attachment; filename="${encodeURI(path.basename(filename))}"`); return response.send(mutatedBuffer); } case 'json': { try { const json = await readCharacterData(filename); if (json === undefined) return response.sendStatus(400); const jsonObject = getCharaCardV2(JSON.parse(json), request.user.directories); unsetPrivateFields(jsonObject); return response.type('json').send(JSON.stringify(jsonObject, null, 4)); } catch { return response.sendStatus(400); } } } return response.sendStatus(400); } catch (err) { console.error('Character export failed', err); response.sendStatus(500); } });