const fs = require('fs'); const path = require('path'); const readline = require('readline'); const express = require('express'); const sanitize = require('sanitize-filename'); const writeFileAtomicSync = require('write-file-atomic').sync; const { jsonParser, urlencodedParser } = require('../express-common'); const { getConfigValue, humanizedISO8601DateTime, tryParse, generateTimestamp, removeOldBackups } = require('../util'); /** * 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 { const isBackupDisabled = getConfigValue('disableChatBackup', false); if (isBackupDisabled) { return; } // replace non-alphanumeric characters with underscores name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase(); const backupFile = path.join(directory, `chat_${name}_${generateTimestamp()}.jsonl`); writeFileAtomicSync(backupFile, chat, 'utf-8'); removeOldBackups(directory, `chat_${name}_`); } catch (err) { console.log(`Could not backup chat for ${name}`, err); } } /** * 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); } } const chatContent = chat.map(obj => JSON.stringify(obj)).join('\n'); return chatContent; } /** * 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, }); } const chatContent = chat.map(obj => JSON.stringify(obj)).join('\n'); return chatContent; } /** * 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; } const router = express.Router(); router.post('/save', jsonParser, 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 = `${sanitize(String(request.body.file_name))}.jsonl`; const filePath = path.join(request.user.directories.chats, directoryName, fileName); writeFileAtomicSync(filePath, jsonlData, 'utf8'); backupChat(request.user.directories.backups, directoryName, jsonlData); return response.send({ result: 'ok' }); } catch (error) { response.send(error); return console.log(error); } }); router.post('/get', jsonParser, 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 = path.join(directoryPath, `${sanitize(String(request.body.file_name))}.jsonl`); const chatFileExists = fs.existsSync(fileName); if (!chatFileExists) { return response.send({}); } const data = fs.readFileSync(fileName, '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', jsonParser, async function (request, response) { 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, request.body.original_file); const pathToRenamedFile = path.join(pathToFolder, request.body.renamed_file); console.log('Old chat name', pathToOriginalFile); console.log('New chat name', pathToRenamedFile); if (!fs.existsSync(pathToOriginalFile) || fs.existsSync(pathToRenamedFile)) { console.log('Either Source or Destination files are not available'); return response.status(400).send({ error: true }); } fs.copyFileSync(pathToOriginalFile, pathToRenamedFile); fs.rmSync(pathToOriginalFile); console.log('Successfully renamed.'); return response.send({ ok: true }); }); router.post('/delete', jsonParser, function (request, response) { if (!request.body) { console.log('no request body seen'); return response.sendStatus(400); } if (request.body.chatfile !== sanitize(request.body.chatfile)) { console.error('Malicious chat name prevented'); return response.sendStatus(403); } const dirName = String(request.body.avatar_url).replace('.png', ''); const fileName = path.join(request.user.directories.chats, dirName, sanitize(String(request.body.chatfile))); const chatFileExists = fs.existsSync(fileName); if (!chatFileExists) { console.log(`Chat file not found '${fileName}'`); return response.sendStatus(400); } else { fs.rmSync(fileName); console.log('Deleted chat file: ' + fileName); } return response.send('ok'); }); router.post('/export', jsonParser, 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.log(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.log(`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.log(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.log(`Chat exported as ${exportfilename}`); return response.status(200).json(successMessage); }); } catch (err) { console.log('chat export failed.'); console.log(err); return response.sendStatus(400); } }); router.post('/group/import', urlencodedParser, 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', urlencodedParser, 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 || 'You'; 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); if (jsonData.histories !== undefined) { // CAI Tools format const chats = importCAIChat(userName, characterName, jsonData); for (const chat of chats) { const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); writeFileAtomicSync(filePath, chat, 'utf8'); } return response.send({ res: true }); } else if (Array.isArray(jsonData.data_visible)) { // oobabooga's format const chat = importOobaChat(userName, characterName, jsonData); const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); writeFileAtomicSync(filePath, chat, 'utf8'); return response.send({ res: true }); } else if (Array.isArray(jsonData.messages)) { // Agnai format const chat = importAgnaiChat(userName, characterName, jsonData); const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); writeFileAtomicSync(filePath, chat, 'utf8'); return response.send({ res: true }); } else { console.log('Incorrect chat format .json'); return response.send({ error: true }); } } if (format === 'jsonl') { const line = data.split('\n')[0]; const jsonData = JSON.parse(line); if (jsonData.user_name !== undefined || jsonData.name !== undefined) { const fileName = `${characterName} - ${humanizedISO8601DateTime()} imported.jsonl`; const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); fs.copyFileSync(pathToUpload, filePath); fs.unlinkSync(pathToUpload); response.send({ res: true }); } else { console.log('Incorrect chat format .jsonl'); return response.send({ error: true }); } } } catch (error) { console.error(error); return response.send({ error: true }); } }); router.post('/group/get', jsonParser, (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', jsonParser, (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.rmSync(pathToFile); return response.send({ ok: true }); } return response.send({ error: true }); }); router.post('/group/save', jsonParser, (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'); backupChat(request.user.directories.backups, String(id), jsonlData); return response.send({ ok: true }); }); module.exports = { router };