Spaces:
Paused
Paused
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import readline from 'node:readline'; | |
| import process from 'node:process'; | |
| import express from 'express'; | |
| import sanitize from 'sanitize-filename'; | |
| import { sync as writeFileAtomicSync } from 'write-file-atomic'; | |
| import _ from 'lodash'; | |
| import validateAvatarUrlMiddleware from '../middleware/validateFileName.js'; | |
| import { | |
| getConfigValue, | |
| humanizedISO8601DateTime, | |
| tryParse, | |
| generateTimestamp, | |
| removeOldBackups, | |
| formatBytes, | |
| } from '../util.js'; | |
| const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean'); | |
| const maxTotalChatBackups = Number(getConfigValue('backups.chat.maxTotalBackups', -1, 'number')); | |
| const throttleInterval = Number(getConfigValue('backups.chat.throttleInterval', 10_000, 'number')); | |
| const checkIntegrity = !!getConfigValue('backups.chat.checkIntegrity', true, 'boolean'); | |
| export const CHAT_BACKUPS_PREFIX = 'chat_'; | |
| /** | |
| * Saves a chat to the backups directory. | |
| * @param {string} directory The user's backups directory. | |
| * @param {string} name The name of the chat. | |
| * @param {string} chat The serialized chat to save. | |
| */ | |
| function backupChat(directory, name, chat) { | |
| try { | |
| if (!isBackupEnabled) { | |
| return; | |
| } | |
| // replace non-alphanumeric characters with underscores | |
| name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase(); | |
| const backupFile = path.join(directory, `${CHAT_BACKUPS_PREFIX}${name}_${generateTimestamp()}.jsonl`); | |
| writeFileAtomicSync(backupFile, chat, 'utf-8'); | |
| removeOldBackups(directory, `${CHAT_BACKUPS_PREFIX}${name}_`); | |
| if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) { | |
| return; | |
| } | |
| removeOldBackups(directory, CHAT_BACKUPS_PREFIX, maxTotalChatBackups); | |
| } catch (err) { | |
| console.error(`Could not backup chat for ${name}`, err); | |
| } | |
| } | |
| /** | |
| * @type {Map<string, import('lodash').DebouncedFunc<function(string, string, string): void>>} | |
| */ | |
| const backupFunctions = new Map(); | |
| /** | |
| * Gets a backup function for a user. | |
| * @param {string} handle User handle | |
| * @returns {function(string, string, string): void} Backup function | |
| */ | |
| function getBackupFunction(handle) { | |
| if (!backupFunctions.has(handle)) { | |
| backupFunctions.set(handle, _.throttle(backupChat, throttleInterval, { leading: true, trailing: true })); | |
| } | |
| return backupFunctions.get(handle) || (() => { }); | |
| } | |
| /** | |
| * Gets a preview message from an array of chat messages | |
| * @param {Array<Object>} messages - Array of chat messages, each with a 'mes' property | |
| * @returns {string} A truncated preview of the last message or empty string if no messages | |
| */ | |
| function getPreviewMessage(messages) { | |
| const strlen = 400; | |
| const lastMessage = messages[messages.length - 1]?.mes; | |
| if (!lastMessage) { | |
| return ''; | |
| } | |
| return lastMessage.length > strlen | |
| ? '...' + lastMessage.substring(lastMessage.length - strlen) | |
| : lastMessage; | |
| } | |
| process.on('exit', () => { | |
| for (const func of backupFunctions.values()) { | |
| func.flush(); | |
| } | |
| }); | |
| /** | |
| * Imports a chat from Ooba's format. | |
| * @param {string} userName User name | |
| * @param {string} characterName Character name | |
| * @param {object} jsonData JSON data | |
| * @returns {string} Chat data | |
| */ | |
| function importOobaChat(userName, characterName, jsonData) { | |
| /** @type {object[]} */ | |
| const chat = [{ | |
| user_name: userName, | |
| character_name: characterName, | |
| create_date: humanizedISO8601DateTime(), | |
| }]; | |
| for (const arr of jsonData.data_visible) { | |
| if (arr[0]) { | |
| const userMessage = { | |
| name: userName, | |
| is_user: true, | |
| send_date: humanizedISO8601DateTime(), | |
| mes: arr[0], | |
| }; | |
| chat.push(userMessage); | |
| } | |
| if (arr[1]) { | |
| const charMessage = { | |
| name: characterName, | |
| is_user: false, | |
| send_date: humanizedISO8601DateTime(), | |
| mes: arr[1], | |
| }; | |
| chat.push(charMessage); | |
| } | |
| } | |
| return chat.map(obj => JSON.stringify(obj)).join('\n'); | |
| } | |
| /** | |
| * Imports a chat from Agnai's format. | |
| * @param {string} userName User name | |
| * @param {string} characterName Character name | |
| * @param {object} jsonData Chat data | |
| * @returns {string} Chat data | |
| */ | |
| function importAgnaiChat(userName, characterName, jsonData) { | |
| /** @type {object[]} */ | |
| const chat = [{ | |
| user_name: userName, | |
| character_name: characterName, | |
| create_date: humanizedISO8601DateTime(), | |
| }]; | |
| for (const message of jsonData.messages) { | |
| const isUser = !!message.userId; | |
| chat.push({ | |
| name: isUser ? userName : characterName, | |
| is_user: isUser, | |
| send_date: humanizedISO8601DateTime(), | |
| mes: message.msg, | |
| }); | |
| } | |
| return chat.map(obj => JSON.stringify(obj)).join('\n'); | |
| } | |
| /** | |
| * Imports a chat from CAI Tools format. | |
| * @param {string} userName User name | |
| * @param {string} characterName Character name | |
| * @param {object} jsonData JSON data | |
| * @returns {string[]} Converted data | |
| */ | |
| function importCAIChat(userName, characterName, jsonData) { | |
| /** | |
| * Converts the chat data to suitable format. | |
| * @param {object} history Imported chat data | |
| * @returns {object[]} Converted chat data | |
| */ | |
| function convert(history) { | |
| const starter = { | |
| user_name: userName, | |
| character_name: characterName, | |
| create_date: humanizedISO8601DateTime(), | |
| }; | |
| const historyData = history.msgs.map((msg) => ({ | |
| name: msg.src.is_human ? userName : characterName, | |
| is_user: msg.src.is_human, | |
| send_date: humanizedISO8601DateTime(), | |
| mes: msg.text, | |
| })); | |
| return [starter, ...historyData]; | |
| } | |
| const newChats = (jsonData.histories.histories ?? []).map(history => newChats.push(convert(history).map(obj => JSON.stringify(obj)).join('\n'))); | |
| return newChats; | |
| } | |
| /** | |
| * Imports a chat from Kobold Lite format. | |
| * @param {string} _userName User name | |
| * @param {string} _characterName Character name | |
| * @param {object} data JSON data | |
| * @returns {string} Chat data | |
| */ | |
| function importKoboldLiteChat(_userName, _characterName, data) { | |
| const inputToken = '{{[INPUT]}}'; | |
| const outputToken = '{{[OUTPUT]}}'; | |
| /** @type {function(string): object} */ | |
| function processKoboldMessage(msg) { | |
| const isUser = msg.includes(inputToken); | |
| return { | |
| name: isUser ? header.user_name : header.character_name, | |
| is_user: isUser, | |
| mes: msg.replaceAll(inputToken, '').replaceAll(outputToken, '').trim(), | |
| send_date: Date.now(), | |
| }; | |
| } | |
| // Create the header | |
| const header = { | |
| user_name: String(data.savedsettings.chatname), | |
| character_name: String(data.savedsettings.chatopponent).split('||$||')[0], | |
| }; | |
| // Format messages | |
| const formattedMessages = data.actions.map(processKoboldMessage); | |
| // Add prompt if available | |
| if (data.prompt) { | |
| formattedMessages.unshift(processKoboldMessage(data.prompt)); | |
| } | |
| // Combine header and messages | |
| const chatData = [header, ...formattedMessages]; | |
| return chatData.map(obj => JSON.stringify(obj)).join('\n'); | |
| } | |
| /** | |
| * Flattens `msg` and `swipes` data from Chub Chat format. | |
| * Only changes enough to make it compatible with the standard chat serialization format. | |
| * @param {string} userName User name | |
| * @param {string} characterName Character name | |
| * @param {string[]} lines serialised JSONL data | |
| * @returns {string} Converted data | |
| */ | |
| function flattenChubChat(userName, characterName, lines) { | |
| function flattenSwipe(swipe) { | |
| return swipe.message ? swipe.message : swipe; | |
| } | |
| function convert(line) { | |
| const lineData = tryParse(line); | |
| if (!lineData) return line; | |
| if (lineData.mes && lineData.mes.message) { | |
| lineData.mes = lineData?.mes.message; | |
| } | |
| if (lineData?.swipes && Array.isArray(lineData.swipes)) { | |
| lineData.swipes = lineData.swipes.map(swipe => flattenSwipe(swipe)); | |
| } | |
| return JSON.stringify(lineData); | |
| } | |
| return (lines ?? []).map(convert).join('\n'); | |
| } | |
| /** | |
| * Imports a chat from RisuAI format. | |
| * @param {string} userName User name | |
| * @param {string} characterName Character name | |
| * @param {object} jsonData Imported chat data | |
| * @returns {string} Chat data | |
| */ | |
| function importRisuChat(userName, characterName, jsonData) { | |
| /** @type {object[]} */ | |
| const chat = [{ | |
| user_name: userName, | |
| character_name: characterName, | |
| create_date: humanizedISO8601DateTime(), | |
| }]; | |
| for (const message of jsonData.data.message) { | |
| const isUser = message.role === 'user'; | |
| chat.push({ | |
| name: message.name ?? (isUser ? userName : characterName), | |
| is_user: isUser, | |
| send_date: Number(message.time ?? Date.now()), | |
| mes: message.data ?? '', | |
| }); | |
| } | |
| return chat.map(obj => JSON.stringify(obj)).join('\n'); | |
| } | |
| /** | |
| * Reads the first line of a file asynchronously. | |
| * @param {string} filePath Path to the file | |
| * @returns {Promise<string>} The first line of the file | |
| */ | |
| function readFirstLine(filePath) { | |
| const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); | |
| const rl = readline.createInterface({ input: stream }); | |
| return new Promise((resolve, reject) => { | |
| let resolved = false; | |
| rl.on('line', line => { | |
| resolved = true; | |
| rl.close(); | |
| stream.close(); | |
| resolve(line); | |
| }); | |
| rl.on('error', error => { | |
| resolved = true; | |
| reject(error); | |
| }); | |
| // Handle empty files | |
| stream.on('end', () => { | |
| if (!resolved) { | |
| resolved = true; | |
| resolve(''); | |
| } | |
| }); | |
| }); | |
| } | |
| /** | |
| * Checks if the chat being saved has the same integrity as the one being loaded. | |
| * @param {string} filePath Path to the chat file | |
| * @param {string} integritySlug Integrity slug | |
| * @returns {Promise<boolean>} Whether the chat is intact | |
| */ | |
| async function checkChatIntegrity(filePath, integritySlug) { | |
| // If the chat file doesn't exist, assume it's intact | |
| if (!fs.existsSync(filePath)) { | |
| return true; | |
| } | |
| // Parse the first line of the chat file as JSON | |
| const firstLine = await readFirstLine(filePath); | |
| const jsonData = tryParse(firstLine); | |
| const chatIntegrity = jsonData?.chat_metadata?.integrity; | |
| // If the chat has no integrity metadata, assume it's intact | |
| if (!chatIntegrity) { | |
| return true; | |
| } | |
| // Check if the integrity matches | |
| return chatIntegrity === integritySlug; | |
| } | |
| /** | |
| * @typedef {Object} ChatInfo | |
| * @property {string} [file_name] - The name of the chat file | |
| * @property {string} [file_size] - The size of the chat file | |
| * @property {number} [chat_items] - The number of chat items in the file | |
| * @property {string} [mes] - The last message in the chat | |
| * @property {number} [last_mes] - The timestamp of the last message | |
| */ | |
| /** | |
| * Reads the information from a chat file. | |
| * @param {string} pathToFile | |
| * @param {object} additionalData | |
| * @returns {Promise<ChatInfo>} | |
| */ | |
| export async function getChatInfo(pathToFile, additionalData = {}, isGroup = false) { | |
| return new Promise(async (res) => { | |
| const stats = await fs.promises.stat(pathToFile); | |
| const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`; | |
| const chatData = { | |
| file_name: path.parse(pathToFile).base, | |
| file_size: fileSizeInKB, | |
| chat_items: 0, | |
| mes: '[The chat is empty]', | |
| last_mes: stats.mtimeMs, | |
| ...additionalData, | |
| }; | |
| if (stats.size === 0 && !isGroup) { | |
| console.warn(`Found an empty chat file: ${pathToFile}`); | |
| res({}); | |
| return; | |
| } | |
| if (stats.size === 0 && isGroup) { | |
| res(chatData); | |
| return; | |
| } | |
| const fileStream = fs.createReadStream(pathToFile); | |
| const rl = readline.createInterface({ | |
| input: fileStream, | |
| crlfDelay: Infinity, | |
| }); | |
| let lastLine; | |
| let itemCounter = 0; | |
| rl.on('line', (line) => { | |
| itemCounter++; | |
| lastLine = line; | |
| }); | |
| rl.on('close', () => { | |
| rl.close(); | |
| if (lastLine) { | |
| const jsonData = tryParse(lastLine); | |
| if (jsonData && (jsonData.name || jsonData.character_name)) { | |
| chatData.chat_items = isGroup ? itemCounter : (itemCounter - 1); | |
| chatData.mes = jsonData['mes'] || '[The message is empty]'; | |
| chatData.last_mes = jsonData['send_date'] || stats.mtimeMs; | |
| res(chatData); | |
| } else { | |
| console.warn('Found an invalid or corrupted chat file:', pathToFile); | |
| res({}); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| export const router = express.Router(); | |
| router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { | |
| try { | |
| const directoryName = String(request.body.avatar_url).replace('.png', ''); | |
| const chatData = request.body.chat; | |
| const jsonlData = chatData.map(JSON.stringify).join('\n'); | |
| const fileName = `${String(request.body.file_name)}.jsonl`; | |
| const filePath = path.join(request.user.directories.chats, directoryName, sanitize(fileName)); | |
| if (checkIntegrity && !request.body.force) { | |
| const integritySlug = chatData?.[0]?.chat_metadata?.integrity; | |
| const isIntact = await checkChatIntegrity(filePath, integritySlug); | |
| if (!isIntact) { | |
| console.error(`Chat integrity check failed for ${filePath}`); | |
| return response.status(400).send({ error: 'integrity' }); | |
| } | |
| } | |
| writeFileAtomicSync(filePath, jsonlData, 'utf8'); | |
| getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData); | |
| return response.send({ result: 'ok' }); | |
| } catch (error) { | |
| console.error(error); | |
| return response.send(error); | |
| } | |
| }); | |
| router.post('/get', validateAvatarUrlMiddleware, function (request, response) { | |
| try { | |
| const dirName = String(request.body.avatar_url).replace('.png', ''); | |
| const directoryPath = path.join(request.user.directories.chats, dirName); | |
| const chatDirExists = fs.existsSync(directoryPath); | |
| //if no chat dir for the character is found, make one with the character name | |
| if (!chatDirExists) { | |
| fs.mkdirSync(directoryPath); | |
| return response.send({}); | |
| } | |
| if (!request.body.file_name) { | |
| return response.send({}); | |
| } | |
| const fileName = `${String(request.body.file_name)}.jsonl`; | |
| const filePath = path.join(directoryPath, sanitize(fileName)); | |
| const chatFileExists = fs.existsSync(filePath); | |
| if (!chatFileExists) { | |
| return response.send({}); | |
| } | |
| const data = fs.readFileSync(filePath, 'utf8'); | |
| const lines = data.split('\n'); | |
| // Iterate through the array of strings and parse each line as JSON | |
| const jsonData = lines.map((l) => { try { return JSON.parse(l); } catch (_) { return; } }).filter(x => x); | |
| return response.send(jsonData); | |
| } catch (error) { | |
| console.error(error); | |
| return response.send({}); | |
| } | |
| }); | |
| router.post('/rename', validateAvatarUrlMiddleware, async function (request, response) { | |
| try { | |
| if (!request.body || !request.body.original_file || !request.body.renamed_file) { | |
| return response.sendStatus(400); | |
| } | |
| const pathToFolder = request.body.is_group | |
| ? request.user.directories.groupChats | |
| : path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', '')); | |
| const pathToOriginalFile = path.join(pathToFolder, sanitize(request.body.original_file)); | |
| const pathToRenamedFile = path.join(pathToFolder, sanitize(request.body.renamed_file)); | |
| const sanitizedFileName = path.parse(pathToRenamedFile).name; | |
| console.debug('Old chat name', pathToOriginalFile); | |
| console.debug('New chat name', pathToRenamedFile); | |
| if (!fs.existsSync(pathToOriginalFile) || fs.existsSync(pathToRenamedFile)) { | |
| console.error('Either Source or Destination files are not available'); | |
| return response.status(400).send({ error: true }); | |
| } | |
| fs.copyFileSync(pathToOriginalFile, pathToRenamedFile); | |
| fs.unlinkSync(pathToOriginalFile); | |
| console.info('Successfully renamed chat file.'); | |
| return response.send({ ok: true, sanitizedFileName }); | |
| } catch (error) { | |
| console.error('Error renaming chat file:', error); | |
| return response.status(500).send({ error: true }); | |
| } | |
| }); | |
| router.post('/delete', validateAvatarUrlMiddleware, function (request, response) { | |
| const dirName = String(request.body.avatar_url).replace('.png', ''); | |
| const fileName = String(request.body.chatfile); | |
| const filePath = path.join(request.user.directories.chats, dirName, sanitize(fileName)); | |
| const chatFileExists = fs.existsSync(filePath); | |
| if (!chatFileExists) { | |
| console.error(`Chat file not found '${filePath}'`); | |
| return response.sendStatus(400); | |
| } | |
| fs.unlinkSync(filePath); | |
| console.info(`Deleted chat file: ${filePath}`); | |
| return response.send('ok'); | |
| }); | |
| router.post('/export', validateAvatarUrlMiddleware, async function (request, response) { | |
| if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) { | |
| return response.sendStatus(400); | |
| } | |
| const pathToFolder = request.body.is_group | |
| ? request.user.directories.groupChats | |
| : path.join(request.user.directories.chats, String(request.body.avatar_url).replace('.png', '')); | |
| let filename = path.join(pathToFolder, request.body.file); | |
| let exportfilename = request.body.exportfilename; | |
| if (!fs.existsSync(filename)) { | |
| const errorMessage = { | |
| message: `Could not find JSONL file to export. Source chat file: ${filename}.`, | |
| }; | |
| console.error(errorMessage.message); | |
| return response.status(404).json(errorMessage); | |
| } | |
| try { | |
| // Short path for JSONL files | |
| if (request.body.format === 'jsonl') { | |
| try { | |
| const rawFile = fs.readFileSync(filename, 'utf8'); | |
| const successMessage = { | |
| message: `Chat saved to ${exportfilename}`, | |
| result: rawFile, | |
| }; | |
| console.info(`Chat exported as ${exportfilename}`); | |
| return response.status(200).json(successMessage); | |
| } catch (err) { | |
| console.error(err); | |
| const errorMessage = { | |
| message: `Could not read JSONL file to export. Source chat file: ${filename}.`, | |
| }; | |
| console.error(errorMessage.message); | |
| return response.status(500).json(errorMessage); | |
| } | |
| } | |
| const readStream = fs.createReadStream(filename); | |
| const rl = readline.createInterface({ | |
| input: readStream, | |
| }); | |
| let buffer = ''; | |
| rl.on('line', (line) => { | |
| const data = JSON.parse(line); | |
| // Skip non-printable/prompt-hidden messages | |
| if (data.is_system) { | |
| return; | |
| } | |
| if (data.mes) { | |
| const name = data.name; | |
| const message = (data?.extra?.display_text || data?.mes || '').replace(/\r?\n/g, '\n'); | |
| buffer += (`${name}: ${message}\n\n`); | |
| } | |
| }); | |
| rl.on('close', () => { | |
| const successMessage = { | |
| message: `Chat saved to ${exportfilename}`, | |
| result: buffer, | |
| }; | |
| console.info(`Chat exported as ${exportfilename}`); | |
| return response.status(200).json(successMessage); | |
| }); | |
| } catch (err) { | |
| console.error('chat export failed.', err); | |
| return response.sendStatus(400); | |
| } | |
| }); | |
| router.post('/group/import', function (request, response) { | |
| try { | |
| const filedata = request.file; | |
| if (!filedata) { | |
| return response.sendStatus(400); | |
| } | |
| const chatname = humanizedISO8601DateTime(); | |
| const pathToUpload = path.join(filedata.destination, filedata.filename); | |
| const pathToNewFile = path.join(request.user.directories.groupChats, `${chatname}.jsonl`); | |
| fs.copyFileSync(pathToUpload, pathToNewFile); | |
| fs.unlinkSync(pathToUpload); | |
| return response.send({ res: chatname }); | |
| } catch (error) { | |
| console.error(error); | |
| return response.send({ error: true }); | |
| } | |
| }); | |
| router.post('/import', validateAvatarUrlMiddleware, function (request, response) { | |
| if (!request.body) return response.sendStatus(400); | |
| const format = request.body.file_type; | |
| const avatarUrl = (request.body.avatar_url).replace('.png', ''); | |
| const characterName = request.body.character_name; | |
| const userName = request.body.user_name || 'User'; | |
| if (!request.file) { | |
| return response.sendStatus(400); | |
| } | |
| try { | |
| const pathToUpload = path.join(request.file.destination, request.file.filename); | |
| const data = fs.readFileSync(pathToUpload, 'utf8'); | |
| if (format === 'json') { | |
| fs.unlinkSync(pathToUpload); | |
| const jsonData = JSON.parse(data); | |
| /** @type {function(string, string, object): string|string[]} */ | |
| let importFunc; | |
| if (jsonData.savedsettings !== undefined) { // Kobold Lite format | |
| importFunc = importKoboldLiteChat; | |
| } else if (jsonData.histories !== undefined) { // CAI Tools format | |
| importFunc = importCAIChat; | |
| } else if (Array.isArray(jsonData.data_visible)) { // oobabooga's format | |
| importFunc = importOobaChat; | |
| } else if (Array.isArray(jsonData.messages)) { // Agnai's format | |
| importFunc = importAgnaiChat; | |
| } else if (jsonData.type === 'risuChat') { // RisuAI format | |
| importFunc = importRisuChat; | |
| } else { // Unknown format | |
| console.error('Incorrect chat format .json'); | |
| return response.send({ error: true }); | |
| } | |
| const handleChat = (chat) => { | |
| const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; | |
| const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); | |
| writeFileAtomicSync(filePath, chat, 'utf8'); | |
| }; | |
| const chat = importFunc(userName, characterName, jsonData); | |
| if (Array.isArray(chat)) { | |
| chat.forEach(handleChat); | |
| } else { | |
| handleChat(chat); | |
| } | |
| return response.send({ res: true }); | |
| } | |
| if (format === 'jsonl') { | |
| let lines = data.split('\n'); | |
| const header = lines[0]; | |
| const jsonData = JSON.parse(header); | |
| if (!(jsonData.user_name !== undefined || jsonData.name !== undefined)) { | |
| console.error('Incorrect chat format .jsonl'); | |
| return response.send({ error: true }); | |
| } | |
| // Do a tiny bit of work to import Chub Chat data | |
| // Processing the entire file is so fast that it's not worth checking if it's a Chub chat first | |
| let flattenedChat = data; | |
| try { | |
| // flattening is unlikely to break, but it's not worth failing to | |
| // import normal chats in an attempt to import a Chub chat | |
| flattenedChat = flattenChubChat(userName, characterName, lines); | |
| } catch (error) { | |
| console.warn('Failed to flatten Chub Chat data: ', error); | |
| } | |
| const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; | |
| const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); | |
| if (flattenedChat !== data) { | |
| writeFileAtomicSync(filePath, flattenedChat, 'utf8'); | |
| } else { | |
| fs.copyFileSync(pathToUpload, filePath); | |
| } | |
| fs.unlinkSync(pathToUpload); | |
| response.send({ res: true }); | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| return response.send({ error: true }); | |
| } | |
| }); | |
| router.post('/group/get', (request, response) => { | |
| if (!request.body || !request.body.id) { | |
| return response.sendStatus(400); | |
| } | |
| const id = request.body.id; | |
| const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`); | |
| if (fs.existsSync(pathToFile)) { | |
| const data = fs.readFileSync(pathToFile, 'utf8'); | |
| const lines = data.split('\n'); | |
| // Iterate through the array of strings and parse each line as JSON | |
| const jsonData = lines.map(line => tryParse(line)).filter(x => x); | |
| return response.send(jsonData); | |
| } else { | |
| return response.send([]); | |
| } | |
| }); | |
| router.post('/group/delete', (request, response) => { | |
| if (!request.body || !request.body.id) { | |
| return response.sendStatus(400); | |
| } | |
| const id = request.body.id; | |
| const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`); | |
| if (fs.existsSync(pathToFile)) { | |
| fs.unlinkSync(pathToFile); | |
| return response.send({ ok: true }); | |
| } | |
| return response.send({ error: true }); | |
| }); | |
| router.post('/group/save', (request, response) => { | |
| if (!request.body || !request.body.id) { | |
| return response.sendStatus(400); | |
| } | |
| const id = request.body.id; | |
| const pathToFile = path.join(request.user.directories.groupChats, `${id}.jsonl`); | |
| if (!fs.existsSync(request.user.directories.groupChats)) { | |
| fs.mkdirSync(request.user.directories.groupChats); | |
| } | |
| let chat_data = request.body.chat; | |
| let jsonlData = chat_data.map(JSON.stringify).join('\n'); | |
| writeFileAtomicSync(pathToFile, jsonlData, 'utf8'); | |
| getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData); | |
| return response.send({ ok: true }); | |
| }); | |
| router.post('/search', validateAvatarUrlMiddleware, function (request, response) { | |
| try { | |
| const { query, avatar_url, group_id } = request.body; | |
| let chatFiles = []; | |
| if (group_id) { | |
| // Find group's chat IDs first | |
| const groupDir = path.join(request.user.directories.groups); | |
| const groupFiles = fs.readdirSync(groupDir) | |
| .filter(file => file.endsWith('.json')); | |
| let targetGroup; | |
| for (const groupFile of groupFiles) { | |
| try { | |
| const groupData = JSON.parse(fs.readFileSync(path.join(groupDir, groupFile), 'utf8')); | |
| if (groupData.id === group_id) { | |
| targetGroup = groupData; | |
| break; | |
| } | |
| } catch (error) { | |
| console.warn(groupFile, 'group file is corrupted:', error); | |
| } | |
| } | |
| if (!targetGroup?.chats) { | |
| return response.send([]); | |
| } | |
| // Find group chat files for given group ID | |
| const groupChatsDir = path.join(request.user.directories.groupChats); | |
| chatFiles = targetGroup.chats | |
| .map(chatId => { | |
| const filePath = path.join(groupChatsDir, `${chatId}.jsonl`); | |
| if (!fs.existsSync(filePath)) return null; | |
| const stats = fs.statSync(filePath); | |
| return { | |
| file_name: chatId, | |
| file_size: formatBytes(stats.size), | |
| path: filePath, | |
| }; | |
| }) | |
| .filter(x => x); | |
| } else { | |
| // Regular character chat directory | |
| const character_name = avatar_url.replace('.png', ''); | |
| const directoryPath = path.join(request.user.directories.chats, character_name); | |
| if (!fs.existsSync(directoryPath)) { | |
| return response.send([]); | |
| } | |
| chatFiles = fs.readdirSync(directoryPath) | |
| .filter(file => file.endsWith('.jsonl')) | |
| .map(fileName => { | |
| const filePath = path.join(directoryPath, fileName); | |
| const stats = fs.statSync(filePath); | |
| return { | |
| file_name: fileName, | |
| file_size: formatBytes(stats.size), | |
| path: filePath, | |
| }; | |
| }); | |
| } | |
| const results = []; | |
| // Search logic | |
| for (const chatFile of chatFiles) { | |
| const data = fs.readFileSync(chatFile.path, 'utf8'); | |
| const messages = data.split('\n') | |
| .map(line => { try { return JSON.parse(line); } catch (_) { return null; } }) | |
| .filter(x => x && typeof x.mes === 'string'); | |
| if (query && messages.length === 0) { | |
| continue; | |
| } | |
| const lastMessage = messages[messages.length - 1]; | |
| const lastMesDate = lastMessage?.send_date || Math.round(fs.statSync(chatFile.path).mtimeMs); | |
| // If no search query, just return metadata | |
| if (!query) { | |
| results.push({ | |
| file_name: chatFile.file_name, | |
| file_size: chatFile.file_size, | |
| message_count: messages.length, | |
| last_mes: lastMesDate, | |
| preview_message: getPreviewMessage(messages), | |
| }); | |
| continue; | |
| } | |
| // Search through title and messages of the chat | |
| const fragments = query.trim().toLowerCase().split(/\s+/).filter(x => x); | |
| const text = [path.parse(chatFile.path).name, ...messages.map(message => message?.mes)].join('\n').toLowerCase(); | |
| const hasMatch = fragments.every(fragment => text.includes(fragment)); | |
| if (hasMatch) { | |
| results.push({ | |
| file_name: chatFile.file_name, | |
| file_size: chatFile.file_size, | |
| message_count: messages.length, | |
| last_mes: lastMesDate, | |
| preview_message: getPreviewMessage(messages), | |
| }); | |
| } | |
| } | |
| // Sort by last message date descending | |
| results.sort((a, b) => new Date(b.last_mes).getTime() - new Date(a.last_mes).getTime()); | |
| return response.send(results); | |
| } catch (error) { | |
| console.error('Chat search error:', error); | |
| return response.status(500).json({ error: 'Search failed' }); | |
| } | |
| }); | |
| router.post('/recent', async function (request, response) { | |
| try { | |
| /** @type {{pngFile?: string, groupId?: string, filePath: string, mtime: number}[]} */ | |
| const allChatFiles = []; | |
| const getCharacterChatFiles = async () => { | |
| const pngDirents = await fs.promises.readdir(request.user.directories.characters, { withFileTypes: true }); | |
| const pngFiles = pngDirents.filter(e => e.isFile() && path.extname(e.name) === '.png').map(e => e.name); | |
| for (const pngFile of pngFiles) { | |
| const chatsDirectory = pngFile.replace('.png', ''); | |
| const pathToChats = path.join(request.user.directories.chats, chatsDirectory); | |
| if (!fs.existsSync(pathToChats)) { | |
| continue; | |
| } | |
| const pathStats = await fs.promises.stat(pathToChats); | |
| if (pathStats.isDirectory()) { | |
| const chatFiles = await fs.promises.readdir(pathToChats); | |
| const jsonlFiles = chatFiles.filter(file => path.extname(file) === '.jsonl'); | |
| for (const file of jsonlFiles) { | |
| const filePath = path.join(pathToChats, file); | |
| const stats = await fs.promises.stat(filePath); | |
| allChatFiles.push({ pngFile, filePath, mtime: stats.mtimeMs }); | |
| } | |
| } | |
| } | |
| }; | |
| const getGroupChatFiles = async () => { | |
| const groupDirents = await fs.promises.readdir(request.user.directories.groups, { withFileTypes: true }); | |
| const groups = groupDirents.filter(e => e.isFile() && path.extname(e.name) === '.json').map(e => e.name); | |
| for (const group of groups) { | |
| try { | |
| const groupPath = path.join(request.user.directories.groups, group); | |
| const groupContents = await fs.promises.readFile(groupPath, 'utf8'); | |
| const groupData = JSON.parse(groupContents); | |
| if (Array.isArray(groupData.chats)) { | |
| for (const chat of groupData.chats) { | |
| const filePath = path.join(request.user.directories.groupChats, `${chat}.jsonl`); | |
| if (!fs.existsSync(filePath)) { | |
| continue; | |
| } | |
| const stats = await fs.promises.stat(filePath); | |
| allChatFiles.push({ groupId: groupData.id, filePath, mtime: stats.mtimeMs }); | |
| } | |
| } | |
| } catch (error) { | |
| // Skip group files that can't be read or parsed | |
| continue; | |
| } | |
| } | |
| }; | |
| await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles()]); | |
| const max = parseInt(request.body.max ?? Number.MAX_SAFE_INTEGER); | |
| const recentChats = allChatFiles.sort((a, b) => b.mtime - a.mtime).slice(0, max); | |
| const jsonFilesPromise = recentChats.map((file) => { | |
| return file.groupId | |
| ? getChatInfo(file.filePath, { group: file.groupId }, true) | |
| : getChatInfo(file.filePath, { avatar: file.pngFile }, false); | |
| }); | |
| 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.sendStatus(500); | |
| } | |
| }); | |