Implement chat backups browse menu (#4862)
* Implement chat backups browse menu * Unify bytes string formatting
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
.chatBackupsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
transition: height var(--animation-duration) ease;
|
||||
max-height: min(25dvh, 250px);
|
||||
overflow-y: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chatBackupsList:not(.open),
|
||||
.chatBackupsList:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chatBackupsListItem {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 5px;
|
||||
padding: 5px 7px;
|
||||
font-size: smaller;
|
||||
background: var(--black30a);
|
||||
}
|
||||
|
||||
.chatBackupsListItemName {
|
||||
opacity: 0.75;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chatBackupsListItemInfo {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.chatBackupsListItemActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chatBackupsListItemActions .right_menu_button {
|
||||
font-size: var(--mainFontSize);
|
||||
}
|
||||
+5
-2
@@ -279,6 +279,7 @@ import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
|
||||
import { initDomHandlers } from './scripts/dom-handlers.js';
|
||||
import { SimpleMutex } from './scripts/util/SimpleMutex.js';
|
||||
import { AudioPlayer } from './scripts/audio-player.js';
|
||||
import { addChatBackupsBrowser } from './scripts/chat-backups.js';
|
||||
|
||||
// API OBJECT FOR EXTERNAL WIRING
|
||||
globalThis.SillyTavern = {
|
||||
@@ -8093,7 +8094,7 @@ export async function displayPastChats(hightlightNames = []) {
|
||||
});
|
||||
|
||||
// Define the search input listener
|
||||
$('#select_chat_search').on('input', function () {
|
||||
$('#select_chat_search').off('input').on('input', function () {
|
||||
const searchQuery = $(this).val();
|
||||
debouncedDisplay(searchQuery);
|
||||
});
|
||||
@@ -8103,6 +8104,8 @@ export async function displayPastChats(hightlightNames = []) {
|
||||
const textSearchElement = $('#select_chat_search');
|
||||
textSearchElement.trigger('click').trigger('focus').trigger('select');
|
||||
}, 200);
|
||||
|
||||
addChatBackupsBrowser();
|
||||
}
|
||||
|
||||
async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {
|
||||
@@ -8932,7 +8935,7 @@ export async function saveChatConditional() {
|
||||
* @param {boolean} [options.refresh] Whether to refresh the group chat list after import
|
||||
* @returns {Promise<string[]>} List of imported file names.
|
||||
*/
|
||||
async function importCharacterChat(formData, { refresh = true } = {}) {
|
||||
export async function importCharacterChat(formData, { refresh = true } = {}) {
|
||||
const fetchResult = await fetch('/api/chats/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { t } from './i18n.js';
|
||||
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
|
||||
import { getFileExtension, sortMoments, timestampToMoment } from './utils.js';
|
||||
import { displayPastChats, getRequestHeaders, importCharacterChat } from '/script.js';
|
||||
import { importGroupChat } from './group-chats.js';
|
||||
|
||||
class BackupsBrowser {
|
||||
/** @type {HTMLElement} */
|
||||
#buttonElement;
|
||||
/** @type {HTMLElement} */
|
||||
#buttonChevronIcon;
|
||||
/** @type {HTMLElement} */
|
||||
#backupsListElement;
|
||||
/** @type {AbortController} */
|
||||
#loadingAbortController;
|
||||
/** @type {boolean} */
|
||||
#isOpen = false;
|
||||
|
||||
get isOpen() {
|
||||
return this.#isOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* View a backup file content.
|
||||
* @param {string} name File name of the backup to view.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async viewBackup(name) {
|
||||
const response = await fetch('/api/backups/chat/download', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: name }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toastr.error(t`Failed to download backup, try again later.`);
|
||||
console.error('Failed to download chat backup:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @type {ChatMessage[]} */
|
||||
const parsedLines = [];
|
||||
const fileText = await response.text();
|
||||
fileText.split('\n').forEach(line => {
|
||||
try {
|
||||
/** @type {ChatMessage} */
|
||||
const lineData = JSON.parse(line);
|
||||
if (lineData?.mes) {
|
||||
parsedLines.push(lineData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse chat backup line:', error);
|
||||
}
|
||||
});
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.classList.add('text_pole', 'monospace', 'textarea_compact', 'margin0', 'height100p');
|
||||
textArea.readOnly = true;
|
||||
textArea.value = parsedLines.map(l => `${l.name} [${timestampToMoment(l.send_date).format('lll')}]\n${l.mes}`).join('\n\n\n');
|
||||
await callGenericPopup(textArea, POPUP_TYPE.TEXT, '', { allowVerticalScrolling: true, large: true, wide: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse chat backup content:', error);
|
||||
toastr.error(t`Failed to parse backup content.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a backup by importing it.
|
||||
* @param {string} name File name of the backup to restore.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async restoreBackup(name) {
|
||||
const response = await fetch('/api/backups/chat/download', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: name }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toastr.error(t`Failed to download backup, try again later.`);
|
||||
console.error('Failed to download chat backup:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], name, { type: 'application/octet-stream' });
|
||||
|
||||
const extension = getFileExtension(file);
|
||||
|
||||
if (extension !== 'jsonl') {
|
||||
toastr.warning(t`Only .jsonl files are supported for chat imports.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const context = SillyTavern.getContext();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set('file_type', extension);
|
||||
formData.set('avatar', file);
|
||||
formData.set('avatar_url', context.characters[context.characterId]?.avatar || '');
|
||||
formData.set('user_name', context.name1);
|
||||
formData.set('character_name', context.name2);
|
||||
|
||||
const importFn = context.groupId ? importGroupChat : importCharacterChat;
|
||||
const result = await importFn(formData, { refresh: false });
|
||||
|
||||
if (result.length === 0) {
|
||||
toastr.error(t`Failed to import chat backup, try again later.`);
|
||||
return;
|
||||
}
|
||||
|
||||
toastr.success(`Chat imported: ${result.join(', ')}`);
|
||||
await displayPastChats(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup file.
|
||||
* @param {string} name File name of the backup to delete.
|
||||
* @returns {Promise<boolean>} True if deleted, false otherwise.
|
||||
*/
|
||||
async deleteBackup(name) {
|
||||
const confirm = await Popup.show.confirm(t`Are you sure?`);
|
||||
if (!confirm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/backups/chat/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: name }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toastr.error(t`Failed to delete backup, try again later.`);
|
||||
console.error('Failed to delete chat backup:', response.statusText);
|
||||
return false;
|
||||
}
|
||||
|
||||
toastr.success(t`Backup deleted successfully.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load backups and populate the list element.
|
||||
* @param {AbortSignal} signal Signal to abort loading.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadBackupsIntoList(signal) {
|
||||
if (!this.#backupsListElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#backupsListElement.innerHTML = '';
|
||||
|
||||
const response = await fetch('/api/backups/chat/get', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to load chat backups list:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {import('../../src/endpoints/chats.js').ChatInfo[]} */
|
||||
const backupsList = await response.json();
|
||||
|
||||
for (const backup of backupsList.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)))) {
|
||||
const listItem = document.createElement('div');
|
||||
listItem.classList.add('chatBackupsListItem');
|
||||
|
||||
const backupName = document.createElement('div');
|
||||
backupName.textContent = backup.file_name;
|
||||
backupName.classList.add('chatBackupsListItemName');
|
||||
|
||||
const backupInfo = document.createElement('div');
|
||||
backupInfo.classList.add('chatBackupsListItemInfo');
|
||||
backupInfo.textContent = `${timestampToMoment(backup.last_mes).format('lll')} (${backup.file_size}, ${backup.chat_items} 💬)`;
|
||||
|
||||
const actionsList = document.createElement('div');
|
||||
actionsList.classList.add('chatBackupsListItemActions');
|
||||
|
||||
const viewButton = document.createElement('div');
|
||||
viewButton.classList.add('right_menu_button', 'fa-solid', 'fa-eye');
|
||||
viewButton.title = t`View backup`;
|
||||
viewButton.addEventListener('click', async () => {
|
||||
await this.viewBackup(backup.file_name);
|
||||
});
|
||||
|
||||
const restoreButton = document.createElement('div');
|
||||
restoreButton.classList.add('right_menu_button', 'fa-solid', 'fa-rotate-left');
|
||||
restoreButton.title = t`Restore backup`;
|
||||
restoreButton.addEventListener('click', async () => {
|
||||
await this.restoreBackup(backup.file_name);
|
||||
});
|
||||
|
||||
const deleteButton = document.createElement('div');
|
||||
deleteButton.classList.add('right_menu_button', 'fa-solid', 'fa-trash');
|
||||
deleteButton.title = t`Delete backup`;
|
||||
deleteButton.addEventListener('click',async () => {
|
||||
const isDeleted = await this.deleteBackup(backup.file_name);
|
||||
if (isDeleted) {
|
||||
listItem.remove();
|
||||
}
|
||||
});
|
||||
|
||||
actionsList.appendChild(viewButton);
|
||||
actionsList.appendChild(restoreButton);
|
||||
actionsList.appendChild(deleteButton);
|
||||
|
||||
listItem.appendChild(backupName);
|
||||
listItem.appendChild(backupInfo);
|
||||
listItem.appendChild(actionsList);
|
||||
|
||||
this.#backupsListElement.appendChild(listItem);
|
||||
}
|
||||
}
|
||||
|
||||
closeBackups() {
|
||||
if (!this.#isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isOpen = false;
|
||||
if (this.#buttonChevronIcon) {
|
||||
this.#buttonChevronIcon.classList.remove('fa-chevron-up');
|
||||
this.#buttonChevronIcon.classList.add('fa-chevron-down');
|
||||
}
|
||||
if (this.#backupsListElement) {
|
||||
this.#backupsListElement.classList.remove('open');
|
||||
this.#backupsListElement.innerHTML = '';
|
||||
}
|
||||
if (this.#loadingAbortController) {
|
||||
this.#loadingAbortController.abort();
|
||||
this.#loadingAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
openBackups() {
|
||||
if (this.#isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isOpen = true;
|
||||
if (this.#buttonChevronIcon) {
|
||||
this.#buttonChevronIcon.classList.remove('fa-chevron-down');
|
||||
this.#buttonChevronIcon.classList.add('fa-chevron-up');
|
||||
}
|
||||
if (this.#backupsListElement) {
|
||||
this.#backupsListElement.classList.add('open');
|
||||
}
|
||||
if (this.#loadingAbortController) {
|
||||
this.#loadingAbortController.abort();
|
||||
this.#loadingAbortController = null;
|
||||
}
|
||||
|
||||
this.#loadingAbortController = new AbortController();
|
||||
this.loadBackupsIntoList(this.#loadingAbortController.signal);
|
||||
}
|
||||
|
||||
renderButton() {
|
||||
if (this.#buttonElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sibling = document.getElementById('select_chat_search');
|
||||
if (!sibling) {
|
||||
console.error('Could not find sibling element for BackupsBrowser button');
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.classList.add('menu_button', 'menu_button_icon');
|
||||
|
||||
const buttonIcon = document.createElement('i');
|
||||
buttonIcon.classList.add('fa-solid', 'fa-box-open');
|
||||
|
||||
const buttonText = document.createElement('span');
|
||||
buttonText.textContent = t`Backups`;
|
||||
buttonText.title = t`Browse chat backups`;
|
||||
|
||||
const chevronIcon = document.createElement('i');
|
||||
chevronIcon.classList.add('fa-solid', 'fa-chevron-down', 'fa-sm');
|
||||
|
||||
button.appendChild(buttonIcon);
|
||||
button.appendChild(buttonText);
|
||||
button.appendChild(chevronIcon);
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
if (this.#isOpen) {
|
||||
this.closeBackups();
|
||||
} else {
|
||||
this.openBackups();
|
||||
}
|
||||
});
|
||||
|
||||
sibling.parentNode.insertBefore(button, sibling);
|
||||
|
||||
this.#buttonElement = button;
|
||||
this.#buttonChevronIcon = chevronIcon;
|
||||
}
|
||||
|
||||
renderBackupsList() {
|
||||
if (this.#backupsListElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sibling = document.getElementById('select_chat_div');
|
||||
if (!sibling) {
|
||||
console.error('Could not find sibling element for BackupsBrowser list');
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.classList.add('chatBackupsList');
|
||||
|
||||
sibling.parentNode.insertBefore(list, sibling);
|
||||
this.#backupsListElement = list;
|
||||
}
|
||||
}
|
||||
|
||||
const backupsBrowser = new BackupsBrowser();
|
||||
|
||||
export function addChatBackupsBrowser() {
|
||||
backupsBrowser.renderButton();
|
||||
backupsBrowser.renderBackupsList();
|
||||
|
||||
// Refresh the backups list if it's already open
|
||||
if (backupsBrowser.isOpen) {
|
||||
backupsBrowser.closeBackups();
|
||||
backupsBrowser.openBackups();
|
||||
}
|
||||
}
|
||||
@@ -2308,6 +2308,8 @@ export async function importGroupChat(formData, { refresh = true } = {}) {
|
||||
await displayPastChats();
|
||||
}
|
||||
}
|
||||
|
||||
return [data.res];
|
||||
}
|
||||
|
||||
return data?.fileNames || [];
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
@import url(css/data-maid.css);
|
||||
@import url(css/secrets.css);
|
||||
@import url(css/backgrounds.css);
|
||||
@import url(css/chat-backups.css);
|
||||
|
||||
:root {
|
||||
interpolate-size: allow-keywords;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import express from 'express';
|
||||
import fs, { promises as fsPromises } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { CHAT_BACKUPS_PREFIX, getChatInfo } from './chats.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.post('/chat/get', async (request, response) => {
|
||||
try {
|
||||
const backupModels = [];
|
||||
const backupFiles = await fsPromises
|
||||
.readdir(request.user.directories.backups, { withFileTypes: true })
|
||||
.then(d => d .filter(d => d.isFile() && path.extname(d.name) === '.jsonl' && d.name.startsWith(CHAT_BACKUPS_PREFIX)).map(d => d.name));
|
||||
|
||||
for (const name of backupFiles) {
|
||||
const filePath = path.join(request.user.directories.backups, name);
|
||||
const info = await getChatInfo(filePath);
|
||||
if (!info || !info.file_name) {
|
||||
continue;
|
||||
}
|
||||
backupModels.push(info);
|
||||
}
|
||||
|
||||
return response.json(backupModels);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/chat/delete', async (request, response) => {
|
||||
try {
|
||||
const { name } = request.body;
|
||||
const filePath = path.join(request.user.directories.backups, sanitize(name));
|
||||
|
||||
if (!path.parse(filePath).base.startsWith(CHAT_BACKUPS_PREFIX)) {
|
||||
console.warn('Attempt to delete non-chat backup file:', name);
|
||||
return response.sendStatus(400);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return response.sendStatus(404);
|
||||
}
|
||||
|
||||
await fsPromises.unlink(filePath);
|
||||
return response.sendStatus(200);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/chat/download', async (request, response) => {
|
||||
try {
|
||||
const { name } = request.body;
|
||||
const filePath = path.join(request.user.directories.backups, sanitize(name));
|
||||
|
||||
if (!path.parse(filePath).base.startsWith(CHAT_BACKUPS_PREFIX)) {
|
||||
console.warn('Attempt to download non-chat backup file:', name);
|
||||
return response.sendStatus(400);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return response.sendStatus(404);
|
||||
}
|
||||
|
||||
return response.download(filePath);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
@@ -365,7 +365,7 @@ async function checkChatIntegrity(filePath, integritySlug) {
|
||||
* @typedef {Object} ChatInfo
|
||||
* @property {string} [file_id] - The name of the chat file (without extension)
|
||||
* @property {string} [file_name] - The name of the chat file (with extension)
|
||||
* @property {string} [file_size] - The size of the chat file
|
||||
* @property {string} [file_size] - The size of the chat file in a human-readable format
|
||||
* @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
|
||||
@@ -383,12 +383,11 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
|
||||
return new Promise(async (res) => {
|
||||
const parsedPath = path.parse(pathToFile);
|
||||
const stats = await fs.promises.stat(pathToFile);
|
||||
const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`;
|
||||
|
||||
const chatData = {
|
||||
file_id: parsedPath.name,
|
||||
file_name: parsedPath.base,
|
||||
file_size: fileSizeInKB,
|
||||
file_size: formatBytes(stats.size),
|
||||
chat_items: 0,
|
||||
mes: '[The chat is empty]',
|
||||
last_mes: stats.mtimeMs,
|
||||
|
||||
@@ -47,6 +47,7 @@ import { router as speechRouter } from './endpoints/speech.js';
|
||||
import { router as azureRouter } from './endpoints/azure.js';
|
||||
import { router as minimaxRouter } from './endpoints/minimax.js';
|
||||
import { router as dataMaidRouter } from './endpoints/data-maid.js';
|
||||
import { router as backupsRouter } from './endpoints/backups.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} ServerStartupResult
|
||||
@@ -175,6 +176,7 @@ export function setupPrivateEndpoints(app) {
|
||||
app.use('/api/azure', azureRouter);
|
||||
app.use('/api/minimax', minimaxRouter);
|
||||
app.use('/api/data-maid', dataMaidRouter);
|
||||
app.use('/api/backups', backupsRouter);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-8
@@ -190,16 +190,11 @@ export function getHexString(length) {
|
||||
|
||||
/**
|
||||
* Formats a byte size into a human-readable string with units
|
||||
* @param {number} bytes - The size in bytes to format
|
||||
* @param {number} numBytes - The size in bytes to format
|
||||
* @returns {string} The formatted string (e.g., "1.5 MB")
|
||||
*/
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
export function formatBytes(numBytes) {
|
||||
return bytes.format(numBytes) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user