From bd1583faa47d921323c5c928d260337aecb31b18 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Thu, 4 Dec 2025 22:05:27 +0200 Subject: [PATCH] Implement chat backups browse menu (#4862) * Implement chat backups browse menu * Unify bytes string formatting --- public/css/chat-backups.css | 54 ++++++ public/script.js | 7 +- public/scripts/chat-backups.js | 335 +++++++++++++++++++++++++++++++++ public/scripts/group-chats.js | 2 + public/style.css | 1 + src/endpoints/backups.js | 75 ++++++++ src/endpoints/chats.js | 5 +- src/server-startup.js | 2 + src/util.js | 11 +- 9 files changed, 479 insertions(+), 13 deletions(-) create mode 100644 public/css/chat-backups.css create mode 100644 public/scripts/chat-backups.js create mode 100644 src/endpoints/backups.js diff --git a/public/css/chat-backups.css b/public/css/chat-backups.css new file mode 100644 index 000000000..9adf2175c --- /dev/null +++ b/public/css/chat-backups.css @@ -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); +} diff --git a/public/script.js b/public/script.js index a2a8a1b9b..20ff16482 100644 --- a/public/script.js +++ b/public/script.js @@ -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} 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, diff --git a/public/scripts/chat-backups.js b/public/scripts/chat-backups.js new file mode 100644 index 000000000..437b87873 --- /dev/null +++ b/public/scripts/chat-backups.js @@ -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} + */ + 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} + */ + 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} 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} + */ + 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(); + } +} diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js index 8a7047f86..57c7552c7 100644 --- a/public/scripts/group-chats.js +++ b/public/scripts/group-chats.js @@ -2308,6 +2308,8 @@ export async function importGroupChat(formData, { refresh = true } = {}) { await displayPastChats(); } } + + return [data.res]; } return data?.fileNames || []; diff --git a/public/style.css b/public/style.css index c0f1a4eab..50e83fce5 100644 --- a/public/style.css +++ b/public/style.css @@ -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; diff --git a/src/endpoints/backups.js b/src/endpoints/backups.js new file mode 100644 index 000000000..62d0a6383 --- /dev/null +++ b/src/endpoints/backups.js @@ -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); + } +}); diff --git a/src/endpoints/chats.js b/src/endpoints/chats.js index 772283493..5d353dde4 100644 --- a/src/endpoints/chats.js +++ b/src/endpoints/chats.js @@ -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, diff --git a/src/server-startup.js b/src/server-startup.js index 34ea6f823..03a16078a 100644 --- a/src/server-startup.js +++ b/src/server-startup.js @@ -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); } /** diff --git a/src/util.js b/src/util.js index f438e637d..a1624ea89 100644 --- a/src/util.js +++ b/src/util.js @@ -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) ?? ''; } /**