|
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 } 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'; |
|
const defaultAvatarPath = './public/img/ai4.png'; |
|
|
|
|
|
const memoryCacheCapacity = getConfigValue('performance.memoryCacheCapacity', '100mb'); |
|
const memoryCache = new MemoryLimitedMap(memoryCacheCapacity); |
|
|
|
const isAndroid = process.platform === 'android'; |
|
|
|
const useShallowCharacters = !!getConfigValue('performance.lazyLoadCharacters', false, 'boolean'); |
|
const useDiskCache = !!getConfigValue('performance.useDiskCache', true, 'boolean'); |
|
|
|
class DiskCache { |
|
|
|
|
|
|
|
|
|
static DIRECTORY = 'characters'; |
|
|
|
|
|
|
|
|
|
|
|
static SYNC_INTERVAL = 5 * 60 * 1000; |
|
|
|
|
|
#instance; |
|
|
|
|
|
#syncInterval; |
|
|
|
|
|
|
|
|
|
|
|
|
|
syncQueue = new Set(); |
|
|
|
|
|
|
|
|
|
|
|
get cachePath() { |
|
return path.join(globalThis.DATA_ROOT, '_cache', DiskCache.DIRECTORY); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
get hashedKeys() { |
|
return fs.readdirSync(this.cachePath); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
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); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
async instance() { |
|
if (this.#instance) { |
|
return this.#instance; |
|
} |
|
|
|
this.#instance = storage.create({ |
|
dir: this.cachePath, |
|
ttl: false, |
|
forgiveParseErrors: true, |
|
|
|
maxFileDescriptors: 100, |
|
}); |
|
await this.#instance.init(); |
|
this.#syncInterval = setInterval(this.#syncCacheEntries.bind(this), DiskCache.SYNC_INTERVAL); |
|
return this.#instance; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
function getCacheKey(inputFile) { |
|
if (fs.existsSync(inputFile)) { |
|
const stat = fs.statSync(inputFile); |
|
return `${inputFile}-${stat.mtimeMs}`; |
|
} |
|
|
|
return inputFile; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function writeCharacterData(inputFile, data, outputFile, request, crop = undefined) { |
|
try { |
|
|
|
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); |
|
} |
|
|
|
|
|
|
|
|
|
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(defaultAvatarPath); |
|
} |
|
} |
|
|
|
const inputImage = await getInputImage(); |
|
|
|
|
|
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; |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function parseImageBuffer(buffer, crop) { |
|
const image = await Jimp.fromBuffer(buffer); |
|
let finalWidth = image.bitmap.width, finalHeight = image.bitmap.height; |
|
|
|
|
|
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 }); |
|
|
|
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); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function tryReadImage(imgPath, crop) { |
|
try { |
|
const rawImg = await Jimp.read(imgPath); |
|
let finalWidth = rawImg.bitmap.width, finalHeight = rawImg.bitmap.height; |
|
|
|
|
|
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 }); |
|
|
|
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); |
|
} |
|
|
|
catch (error) { |
|
console.error(`Failed to read image: ${imgPath}`, error); |
|
return fs.readFileSync(imgPath); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 }; |
|
}; |
|
|
|
|
|
const calculateDataSize = (data) => { |
|
return typeof data === 'object' ? Object.values(data).reduce((acc, val) => acc + String(val).length, 0) : 0; |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
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), |
|
}, |
|
}, |
|
}; |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, |
|
}; |
|
} |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function convertToV2(char, directories) { |
|
|
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
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) => { |
|
|
|
const v2Value = _.get(char.data, v2Path); |
|
if (_.isUndefined(v2Value)) { |
|
let defaultValue = undefined; |
|
|
|
|
|
if (v2Path === 'extensions.talkativeness') { |
|
defaultValue = 0.5; |
|
} |
|
|
|
if (v2Path === 'extensions.fav') { |
|
defaultValue = false; |
|
} |
|
|
|
if (!_.isUndefined(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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function charaFormatData(data, directories) { |
|
|
|
const char = tryParse(data.json_data) || {}; |
|
|
|
|
|
const getAlternateGreetings = data => { |
|
if (Array.isArray(data.alternate_greetings)) return data.alternate_greetings; |
|
if (typeof data.alternate_greetings === 'string') return [data.alternate_greetings]; |
|
return []; |
|
}; |
|
|
|
|
|
_.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 || ''); |
|
|
|
|
|
_.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 || []); |
|
|
|
|
|
_.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 || ''); |
|
|
|
|
|
_.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)); |
|
|
|
|
|
_.set(char, 'data.extensions.talkativeness', data.talkativeness || 0.5); |
|
_.set(char, 'data.extensions.fav', data.fav == 'true'); |
|
_.set(char, 'data.extensions.world', data.world || ''); |
|
|
|
|
|
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.group_only_greetings', data.group_only_greetings ?? []); |
|
|
|
if (data.world) { |
|
try { |
|
const file = readWorldInfoFile(directories, data.world, false); |
|
|
|
|
|
if (file && file.originalData) { |
|
_.set(char, 'data.character_book', file.originalData); |
|
} |
|
|
|
|
|
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); |
|
|
|
_.set(char, 'data.extensions', deepMerge(char.data.extensions, extensions)); |
|
} catch { |
|
console.warn(`Failed to parse extensions JSON: ${data.extensions}`); |
|
} |
|
} |
|
|
|
return char; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
function convertWorldInfoToCharacterBook(name, entries) { |
|
|
|
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, |
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(defaultAvatarPath, JSON.stringify(char), fileName, context.request); |
|
return result ? fileName : ''; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'); |
|
} |
|
|
|
|
|
let avatar = defaultAvatarPath; |
|
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 : ''; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(defaultAvatarPath, 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(defaultAvatarPath, charJSON, pngName, request); |
|
return result ? pngName : ''; |
|
} else if (jsonData.char_name !== undefined) { |
|
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(defaultAvatarPath, charJSON, pngName, request); |
|
return result ? pngName : ''; |
|
} |
|
|
|
return ''; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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', 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(defaultAvatarPath, 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 { |
|
|
|
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); |
|
|
|
|
|
await writeCharacterData(oldAvatarPath, newData, newInternalName, request); |
|
|
|
|
|
if (fs.existsSync(oldChatsPath) && !fs.existsSync(newChatsPath)) { |
|
fs.cpSync(oldChatsPath, newChatsPath, { recursive: true }); |
|
fs.rmSync(oldChatsPath, { recursive: true, force: true }); |
|
} |
|
|
|
|
|
fs.unlinkSync(oldAvatarPath); |
|
|
|
|
|
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); |
|
|
|
|
|
response.setHeader('Clear-Site-Data', '"cache"'); |
|
} |
|
|
|
return response.sendStatus(200); |
|
} |
|
catch { |
|
console.error('An error occured, character edit invalidated.'); |
|
} |
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
|
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 occured, character edit invalidated.', err); |
|
} |
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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', ''); |
|
|
|
|
|
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); |
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 }); |
|
} |
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
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('_'); |
|
} else { |
|
baseName = nameParts.join('_'); |
|
} |
|
|
|
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); |
|
} |
|
}); |
|
|