|
|
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, |
|
|
humanizedDateTime, |
|
|
tryParse, |
|
|
generateTimestamp, |
|
|
removeOldBackups, |
|
|
formatBytes, |
|
|
tryWriteFileSync, |
|
|
tryReadFileSync, |
|
|
tryDeleteFile, |
|
|
readFirstLine, |
|
|
} 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_'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function backupChat(directory, name, data, backupPrefix = CHAT_BACKUPS_PREFIX) { |
|
|
try { |
|
|
if (!isBackupEnabled) { return; } |
|
|
if (!fs.existsSync(directory)) { |
|
|
console.error(`The chat couldn't be backed up because no directory exists at ${directory}!`); |
|
|
} |
|
|
|
|
|
name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase(); |
|
|
|
|
|
const backupFile = path.join(directory, `${backupPrefix}${name}_${generateTimestamp()}.jsonl`); |
|
|
|
|
|
tryWriteFileSync(backupFile, data); |
|
|
removeOldBackups(directory, `${backupPrefix}${name}_`); |
|
|
if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) { |
|
|
return; |
|
|
} |
|
|
removeOldBackups(directory, backupPrefix, maxTotalChatBackups); |
|
|
} catch (err) { |
|
|
console.error(`Could not backup chat for ${name}`, err); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const backupFunctions = new Map(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getBackupFunction(handle) { |
|
|
if (!backupFunctions.has(handle)) { |
|
|
backupFunctions.set(handle, _.throttle(backupChat, throttleInterval, { leading: true, trailing: true })); |
|
|
} |
|
|
return backupFunctions.get(handle) || (() => { }); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function importOobaChat(userName, characterName, jsonData) { |
|
|
|
|
|
const chat = [{ |
|
|
chat_metadata: {}, |
|
|
user_name: 'unused', |
|
|
character_name: 'unused', |
|
|
}]; |
|
|
|
|
|
for (const arr of jsonData.data_visible) { |
|
|
if (arr[0]) { |
|
|
const userMessage = { |
|
|
name: userName, |
|
|
is_user: true, |
|
|
send_date: new Date().toISOString(), |
|
|
mes: arr[0], |
|
|
extra: {}, |
|
|
}; |
|
|
chat.push(userMessage); |
|
|
} |
|
|
if (arr[1]) { |
|
|
const charMessage = { |
|
|
name: characterName, |
|
|
is_user: false, |
|
|
send_date: new Date().toISOString(), |
|
|
mes: arr[1], |
|
|
extra: {}, |
|
|
}; |
|
|
chat.push(charMessage); |
|
|
} |
|
|
} |
|
|
|
|
|
return chat.map(obj => JSON.stringify(obj)).join('\n'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function importAgnaiChat(userName, characterName, jsonData) { |
|
|
|
|
|
const chat = [{ |
|
|
chat_metadata: {}, |
|
|
user_name: 'unused', |
|
|
character_name: 'unused', |
|
|
}]; |
|
|
|
|
|
for (const message of jsonData.messages) { |
|
|
const isUser = !!message.userId; |
|
|
chat.push({ |
|
|
name: isUser ? userName : characterName, |
|
|
is_user: isUser, |
|
|
send_date: new Date().toISOString(), |
|
|
mes: message.msg, |
|
|
extra: {}, |
|
|
}); |
|
|
} |
|
|
|
|
|
return chat.map(obj => JSON.stringify(obj)).join('\n'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function importCAIChat(userName, characterName, jsonData) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function convert(history) { |
|
|
const starter = { |
|
|
chat_metadata: {}, |
|
|
user_name: 'unused', |
|
|
character_name: 'unused', |
|
|
}; |
|
|
|
|
|
const historyData = history.msgs.map((msg) => ({ |
|
|
name: msg.src.is_human ? userName : characterName, |
|
|
is_user: msg.src.is_human, |
|
|
send_date: new Date().toISOString(), |
|
|
mes: msg.text, |
|
|
extra: {}, |
|
|
})); |
|
|
|
|
|
return [starter, ...historyData]; |
|
|
} |
|
|
|
|
|
const newChats = (jsonData.histories.histories ?? []).map(history => newChats.push(convert(history).map(obj => JSON.stringify(obj)).join('\n'))); |
|
|
return newChats; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function importKoboldLiteChat(_userName, _characterName, data) { |
|
|
const inputToken = '{{[INPUT]}}'; |
|
|
const outputToken = '{{[OUTPUT]}}'; |
|
|
|
|
|
|
|
|
function processKoboldMessage(msg) { |
|
|
const isUser = msg.includes(inputToken); |
|
|
return { |
|
|
name: isUser ? userName : characterName, |
|
|
is_user: isUser, |
|
|
mes: msg.replaceAll(inputToken, '').replaceAll(outputToken, '').trim(), |
|
|
send_date: new Date().toISOString(), |
|
|
extra: {}, |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
const userName = String(data.savedsettings.chatname); |
|
|
const characterName = String(data.savedsettings.chatopponent).split('||$||')[0]; |
|
|
const header = { |
|
|
chat_metadata: {}, |
|
|
user_name: 'unused', |
|
|
character_name: 'unused', |
|
|
}; |
|
|
|
|
|
const formattedMessages = data.actions.map(processKoboldMessage); |
|
|
|
|
|
if (data.prompt) { |
|
|
formattedMessages.unshift(processKoboldMessage(data.prompt)); |
|
|
} |
|
|
|
|
|
const chatData = [header, ...formattedMessages]; |
|
|
return chatData.map(obj => JSON.stringify(obj)).join('\n'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function importRisuChat(userName, characterName, jsonData) { |
|
|
|
|
|
const chat = [{ |
|
|
chat_metadata: {}, |
|
|
user_name: 'unused', |
|
|
character_name: 'unused', |
|
|
}]; |
|
|
|
|
|
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: new Date(Number(message.time ?? Date.now())).toISOString(), |
|
|
mes: message.data ?? '', |
|
|
extra: {}, |
|
|
}); |
|
|
} |
|
|
|
|
|
return chat.map(obj => JSON.stringify(obj)).join('\n'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function checkChatIntegrity(filePath, integritySlug) { |
|
|
|
|
|
if (!fs.existsSync(filePath)) { |
|
|
return true; |
|
|
} |
|
|
|
|
|
|
|
|
const firstLine = await readFirstLine(filePath); |
|
|
const jsonData = tryParse(firstLine); |
|
|
const chatIntegrity = jsonData?.chat_metadata?.integrity; |
|
|
|
|
|
|
|
|
if (!chatIntegrity) { |
|
|
console.debug(`File "${filePath}" does not have integrity metadata matching "${integritySlug}". The integrity validation has been skipped.`); |
|
|
return true; |
|
|
} |
|
|
|
|
|
|
|
|
return chatIntegrity === integritySlug; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function getChatInfo(pathToFile, additionalData = {}, withMetadata = false) { |
|
|
return new Promise(async (res) => { |
|
|
const parsedPath = path.parse(pathToFile); |
|
|
const stats = await fs.promises.stat(pathToFile); |
|
|
|
|
|
const chatData = { |
|
|
file_id: parsedPath.name, |
|
|
file_name: parsedPath.base, |
|
|
file_size: formatBytes(stats.size), |
|
|
chat_items: 0, |
|
|
mes: '[The chat is empty]', |
|
|
last_mes: stats.mtimeMs, |
|
|
...additionalData, |
|
|
}; |
|
|
|
|
|
if (stats.size === 0) { |
|
|
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) => { |
|
|
if (withMetadata && itemCounter === 0) { |
|
|
const jsonData = tryParse(line); |
|
|
if (jsonData && _.isObjectLike(jsonData.chat_metadata)) { |
|
|
chatData.chat_metadata = jsonData.chat_metadata; |
|
|
} |
|
|
} |
|
|
itemCounter++; |
|
|
lastLine = line; |
|
|
}); |
|
|
rl.on('close', () => { |
|
|
rl.close(); |
|
|
|
|
|
if (lastLine) { |
|
|
const jsonData = tryParse(lastLine); |
|
|
if (jsonData && (jsonData.name || jsonData.character_name || jsonData.chat_metadata)) { |
|
|
chatData.chat_items = (itemCounter - 1); |
|
|
chatData.mes = jsonData['mes'] || '[The message is empty]'; |
|
|
chatData.last_mes = jsonData['send_date'] || new Date(Math.round(stats.mtimeMs)).toISOString(); |
|
|
|
|
|
res(chatData); |
|
|
} else { |
|
|
console.warn('Found an invalid or corrupted chat file:', pathToFile); |
|
|
res({}); |
|
|
} |
|
|
} |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
export const router = express.Router(); |
|
|
|
|
|
|
|
|
class IntegrityMismatchError extends Error { |
|
|
constructor(...params) { |
|
|
|
|
|
super(...params); |
|
|
|
|
|
if (Error.captureStackTrace) { |
|
|
Error.captureStackTrace(this, IntegrityMismatchError); |
|
|
} |
|
|
this.date = new Date(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function trySaveChat(chatData, filePath, skipIntegrityCheck = false, handle, cardName, backupDirectory) { |
|
|
const jsonlData = chatData?.map(m => JSON.stringify(m)).join('\n'); |
|
|
|
|
|
const doIntegrityCheck = (checkIntegrity && !skipIntegrityCheck); |
|
|
const chatIntegritySlug = doIntegrityCheck ? chatData?.[0]?.chat_metadata?.integrity : undefined; |
|
|
|
|
|
if (chatIntegritySlug && !await checkChatIntegrity(filePath, chatIntegritySlug)) { |
|
|
throw new IntegrityMismatchError(`Chat integrity check failed for "${filePath}". The expected integrity slug was "${chatIntegritySlug}".`); |
|
|
} |
|
|
tryWriteFileSync(filePath, jsonlData); |
|
|
getBackupFunction(handle)(backupDirectory, cardName, jsonlData); |
|
|
} |
|
|
|
|
|
router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { |
|
|
try { |
|
|
const handle = request.user.profile.handle; |
|
|
const cardName = String(request.body.avatar_url).replace('.png', ''); |
|
|
const chatData = request.body.chat; |
|
|
const chatFileName = `${String(request.body.file_name)}.jsonl`; |
|
|
const chatFilePath = path.join(request.user.directories.chats, cardName, sanitize(chatFileName)); |
|
|
|
|
|
if (Array.isArray(chatData)) { |
|
|
await trySaveChat(chatData, chatFilePath, request.body.force, handle, cardName, request.user.directories.backups); |
|
|
return response.send({ ok: true }); |
|
|
} else { |
|
|
return response.status(400).send({ error: 'The request\'s body.chat is not an array.' }); |
|
|
} |
|
|
} catch (error) { |
|
|
if (error instanceof IntegrityMismatchError) { |
|
|
console.error(error.message); |
|
|
return response.status(400).send({ error: 'integrity' }); |
|
|
} |
|
|
console.error(error); |
|
|
return response.status(500).send({ error: 'An error has occurred, see the console logs for more information.' }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getChatData(chatFilePath) { |
|
|
let chatData = []; |
|
|
|
|
|
const chatJSON = tryReadFileSync(chatFilePath) ?? ''; |
|
|
if (chatJSON.length > 0) { |
|
|
const lines = chatJSON.split('\n'); |
|
|
|
|
|
chatData = lines.map(line => tryParse(line)).filter(x => x); |
|
|
} else { |
|
|
console.warn(`File not found: ${chatFilePath}. The chat does not exist or is empty.`); |
|
|
} |
|
|
|
|
|
return chatData; |
|
|
} |
|
|
|
|
|
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 (!chatDirExists) { |
|
|
fs.mkdirSync(directoryPath); |
|
|
return response.send({}); |
|
|
} |
|
|
|
|
|
if (!request.body.file_name) { |
|
|
return response.send({}); |
|
|
} |
|
|
|
|
|
const chatFileName = `${String(request.body.file_name)}.jsonl`; |
|
|
const chatFilePath = path.join(directoryPath, sanitize(chatFileName)); |
|
|
|
|
|
return response.send(getChatData(chatFilePath)); |
|
|
} 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) { |
|
|
try { |
|
|
if (!path.extname(request.body.chatfile)) { |
|
|
request.body.chatfile += '.jsonl'; |
|
|
} |
|
|
|
|
|
const dirName = String(request.body.avatar_url).replace('.png', ''); |
|
|
const chatFileName = String(request.body.chatfile); |
|
|
const chatFilePath = path.join(request.user.directories.chats, dirName, sanitize(chatFileName)); |
|
|
|
|
|
if (tryDeleteFile(chatFilePath)) { |
|
|
return response.send({ ok: true }); |
|
|
} else { |
|
|
console.error('The chat file was not deleted.'); |
|
|
return response.sendStatus(400); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error(error); |
|
|
return response.sendStatus(500); |
|
|
} |
|
|
}); |
|
|
|
|
|
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 { |
|
|
|
|
|
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); |
|
|
|
|
|
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 = humanizedDateTime(); |
|
|
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'; |
|
|
const fileNames = []; |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
let importFunc; |
|
|
|
|
|
if (jsonData.savedsettings !== undefined) { |
|
|
importFunc = importKoboldLiteChat; |
|
|
} else if (jsonData.histories !== undefined) { |
|
|
importFunc = importCAIChat; |
|
|
} else if (Array.isArray(jsonData.data_visible)) { |
|
|
importFunc = importOobaChat; |
|
|
} else if (Array.isArray(jsonData.messages)) { |
|
|
importFunc = importAgnaiChat; |
|
|
} else if (jsonData.type === 'risuChat') { |
|
|
importFunc = importRisuChat; |
|
|
} else { |
|
|
console.error('Incorrect chat format .json'); |
|
|
return response.send({ error: true }); |
|
|
} |
|
|
|
|
|
const handleChat = (chat) => { |
|
|
const fileName = `${characterName} - ${humanizedDateTime()} imported.jsonl`; |
|
|
const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); |
|
|
fileNames.push(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, fileNames }); |
|
|
} |
|
|
|
|
|
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 || jsonData.chat_metadata !== undefined)) { |
|
|
console.error('Incorrect chat format .jsonl'); |
|
|
return response.send({ error: true }); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let flattenedChat = data; |
|
|
try { |
|
|
|
|
|
|
|
|
flattenedChat = flattenChubChat(userName, characterName, lines); |
|
|
} catch (error) { |
|
|
console.warn('Failed to flatten Chub Chat data: ', error); |
|
|
} |
|
|
|
|
|
const fileName = `${characterName} - ${humanizedDateTime()} imported.jsonl`; |
|
|
const filePath = path.join(request.user.directories.chats, avatarUrl, fileName); |
|
|
fileNames.push(fileName); |
|
|
if (flattenedChat !== data) { |
|
|
writeFileAtomicSync(filePath, flattenedChat, 'utf8'); |
|
|
} else { |
|
|
fs.copyFileSync(pathToUpload, filePath); |
|
|
} |
|
|
fs.unlinkSync(pathToUpload); |
|
|
response.send({ res: true, fileNames }); |
|
|
} |
|
|
} 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 chatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`); |
|
|
|
|
|
return response.send(getChatData(chatFilePath)); |
|
|
}); |
|
|
|
|
|
router.post('/group/delete', (request, response) => { |
|
|
try { |
|
|
if (!request.body || !request.body.id) { |
|
|
return response.sendStatus(400); |
|
|
} |
|
|
|
|
|
const id = request.body.id; |
|
|
const chatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`); |
|
|
|
|
|
|
|
|
if (tryDeleteFile(chatFilePath)) { |
|
|
return response.send({ ok: true }); |
|
|
} else { |
|
|
console.error('The group chat file was not deleted.\''); |
|
|
return response.sendStatus(400); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error(error); |
|
|
return response.sendStatus(500); |
|
|
} |
|
|
}); |
|
|
|
|
|
router.post('/group/save', async function (request, response) { |
|
|
try { |
|
|
if (!request.body || !request.body.id) { |
|
|
return response.sendStatus(400); |
|
|
} |
|
|
|
|
|
const id = request.body.id; |
|
|
const handle = request.user.profile.handle; |
|
|
const chatFilePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`)); |
|
|
const chatData = request.body.chat; |
|
|
|
|
|
if (Array.isArray(chatData)) { |
|
|
await trySaveChat(chatData, chatFilePath, request.body.force, handle, String(id), request.user.directories.backups); |
|
|
return response.send({ ok: true }); |
|
|
} |
|
|
else { |
|
|
return response.status(400).send({ error: 'The request\'s body.chat is not an array.' }); |
|
|
} |
|
|
} catch (error) { |
|
|
if (error instanceof IntegrityMismatchError) { |
|
|
console.error(error.message); |
|
|
return response.status(400).send({ error: 'integrity' }); |
|
|
} |
|
|
console.error(error); |
|
|
return response.status(500).send({ error: 'An error has occurred, see the console logs for more information.' }); |
|
|
} |
|
|
}); |
|
|
|
|
|
router.post('/search', validateAvatarUrlMiddleware, function (request, response) { |
|
|
try { |
|
|
const { query, avatar_url, group_id } = request.body; |
|
|
let chatFiles = []; |
|
|
|
|
|
if (group_id) { |
|
|
|
|
|
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([]); |
|
|
} |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
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 = []; |
|
|
|
|
|
|
|
|
for (const chatFile of chatFiles) { |
|
|
const data = getChatData(chatFile.path); |
|
|
const messages = data.filter(x => x && typeof x.mes === 'string'); |
|
|
|
|
|
if (query && messages.length === 0) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
const lastMessage = messages[messages.length - 1]; |
|
|
const lastMesDate = lastMessage?.send_date || new Date(fs.statSync(chatFile.path).mtimeMs).toISOString(); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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), |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
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) { |
|
|
|
|
|
continue; |
|
|
} |
|
|
} |
|
|
}; |
|
|
|
|
|
const getRootChatFiles = async () => { |
|
|
const dirents = await fs.promises.readdir(request.user.directories.chats, { withFileTypes: true }); |
|
|
const chatFiles = dirents.filter(e => e.isFile() && path.extname(e.name) === '.jsonl').map(e => e.name); |
|
|
|
|
|
for (const file of chatFiles) { |
|
|
const filePath = path.join(request.user.directories.chats, file); |
|
|
const stats = await fs.promises.stat(filePath); |
|
|
allChatFiles.push({ filePath, mtime: stats.mtimeMs }); |
|
|
} |
|
|
}; |
|
|
|
|
|
await Promise.allSettled([getCharacterChatFiles(), getGroupChatFiles(), getRootChatFiles()]); |
|
|
|
|
|
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) => { |
|
|
const withMetadata = !!request.body.metadata; |
|
|
return file.groupId |
|
|
? getChatInfo(file.filePath, { group: file.groupId }, withMetadata) |
|
|
: getChatInfo(file.filePath, { avatar: file.pngFile }, withMetadata); |
|
|
}); |
|
|
|
|
|
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); |
|
|
} |
|
|
}); |
|
|
|