462 lines
16 KiB
JavaScript

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 };