Implement chat backups browse menu (#4862) * Implement chat backups browse menu * Unify bytes string formatting

bd1583faa47d921323c5c928d260337aecb31b18

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
9 files changed, +479 -13Ignore whitespace
public/css/chat-backups.css+54 -0
@@ -0,0 +1,54 @@
1+.chatBackupsList {
2+ display: flex;
3+ flex-direction: column;
4+ gap: 5px;
5+ transition: height var(--animation-duration) ease;
6+ max-height: min(25dvh, 250px);
7+ overflow-y: auto;
8+ border-radius: 10px;
9+ border: 1px solid var(--SmartThemeBorderColor);
10+ padding: 5px;
11+ width: 100%;
12+}
13+
14+.chatBackupsList:not(.open),
15+.chatBackupsList:empty {
16+ display: none;
17+}
18+
19+.chatBackupsListItem {
20+ display: flex;
21+ flex-wrap: wrap;
22+ flex-direction: row;
23+ align-items: center;
24+ justify-content: space-between;
25+ gap: 10px;
26+ width: 100%;
27+ border: 1px solid var(--SmartThemeBorderColor);
28+ border-radius: 5px;
29+ padding: 5px 7px;
30+ font-size: smaller;
31+ background: var(--black30a);
32+}
33+
34+.chatBackupsListItemName {
35+ opacity: 0.75;
36+ flex-grow: 1;
37+ overflow: hidden;
38+ text-overflow: ellipsis;
39+ white-space: nowrap;
40+}
41+
42+.chatBackupsListItemInfo {
43+ opacity: 0.75;
44+}
45+
46+.chatBackupsListItemActions {
47+ display: flex;
48+ align-items: center;
49+ gap: 5px;
50+}
51+
52+.chatBackupsListItemActions .right_menu_button {
53+ font-size: var(--mainFontSize);
54+}
public/script.js+5 -2
@@ -279,6 +279,7 @@ import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
279279import { initDomHandlers } from './scripts/dom-handlers.js';
280280import { SimpleMutex } from './scripts/util/SimpleMutex.js';
281281import { AudioPlayer } from './scripts/audio-player.js';
282+import { addChatBackupsBrowser } from './scripts/chat-backups.js';
282283
283284// API OBJECT FOR EXTERNAL WIRING
284285globalThis.SillyTavern = {
@@ -8093,7 +8094,7 @@ export async function displayPastChats(hightlightNames = []) {
80938094 });
80948095
80958096 // Define the search input listener
80968097 $('#select_chat_search').off('input').on('input', function () {
80978098 const searchQuery = $(this).val();
80988099 debouncedDisplay(searchQuery);
80998100 });
@@ -8103,6 +8104,8 @@ export async function displayPastChats(hightlightNames = []) {
81038104 const textSearchElement = $('#select_chat_search');
81048105 textSearchElement.trigger('click').trigger('focus').trigger('select');
81058106 }, 200);
8107+
8108+ addChatBackupsBrowser();
81068109}
81078110
81088111async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {
@@ -8932,7 +8935,7 @@ export async function saveChatConditional() {
89328935 * @param {boolean} [options.refresh] Whether to refresh the group chat list after import
89338936 * @returns {Promise<string[]>} List of imported file names.
89348937 */
89358938export async function importCharacterChat(formData, { refresh = true } = {}) {
89368939 const fetchResult = await fetch('/api/chats/import', {
89378940 method: 'POST',
89388941 body: formData,
public/scripts/chat-backups.js+335 -0
@@ -0,0 +1,335 @@
1+import { t } from './i18n.js';
2+import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
3+import { getFileExtension, sortMoments, timestampToMoment } from './utils.js';
4+import { displayPastChats, getRequestHeaders, importCharacterChat } from '/script.js';
5+import { importGroupChat } from './group-chats.js';
6+
7+class BackupsBrowser {
8+ /** @type {HTMLElement} */
9+ #buttonElement;
10+ /** @type {HTMLElement} */
11+ #buttonChevronIcon;
12+ /** @type {HTMLElement} */
13+ #backupsListElement;
14+ /** @type {AbortController} */
15+ #loadingAbortController;
16+ /** @type {boolean} */
17+ #isOpen = false;
18+
19+ get isOpen() {
20+ return this.#isOpen;
21+ }
22+
23+ /**
24+ * View a backup file content.
25+ * @param {string} name File name of the backup to view.
26+ * @returns {Promise<void>}
27+ */
28+ async viewBackup(name) {
29+ const response = await fetch('/api/backups/chat/download', {
30+ method: 'POST',
31+ headers: getRequestHeaders(),
32+ body: JSON.stringify({ name: name }),
33+ });
34+
35+ if (!response.ok) {
36+ toastr.error(t`Failed to download backup, try again later.`);
37+ console.error('Failed to download chat backup:', response.statusText);
38+ return;
39+ }
40+
41+ try {
42+ /** @type {ChatMessage[]} */
43+ const parsedLines = [];
44+ const fileText = await response.text();
45+ fileText.split('\n').forEach(line => {
46+ try {
47+ /** @type {ChatMessage} */
48+ const lineData = JSON.parse(line);
49+ if (lineData?.mes) {
50+ parsedLines.push(lineData);
51+ }
52+ } catch (error) {
53+ console.error('Failed to parse chat backup line:', error);
54+ }
55+ });
56+ const textArea = document.createElement('textarea');
57+ textArea.classList.add('text_pole', 'monospace', 'textarea_compact', 'margin0', 'height100p');
58+ textArea.readOnly = true;
59+ textArea.value = parsedLines.map(l => `${l.name} [${timestampToMoment(l.send_date).format('lll')}]\n${l.mes}`).join('\n\n\n');
60+ await callGenericPopup(textArea, POPUP_TYPE.TEXT, '', { allowVerticalScrolling: true, large: true, wide: true });
61+ } catch (error) {
62+ console.error('Failed to parse chat backup content:', error);
63+ toastr.error(t`Failed to parse backup content.`);
64+ return;
65+ }
66+ }
67+
68+ /**
69+ * Restore a backup by importing it.
70+ * @param {string} name File name of the backup to restore.
71+ * @returns {Promise<void>}
72+ */
73+ async restoreBackup(name) {
74+ const response = await fetch('/api/backups/chat/download', {
75+ method: 'POST',
76+ headers: getRequestHeaders(),
77+ body: JSON.stringify({ name: name }),
78+ });
79+
80+ if (!response.ok) {
81+ toastr.error(t`Failed to download backup, try again later.`);
82+ console.error('Failed to download chat backup:', response.statusText);
83+ return;
84+ }
85+
86+ const blob = await response.blob();
87+ const file = new File([blob], name, { type: 'application/octet-stream' });
88+
89+ const extension = getFileExtension(file);
90+
91+ if (extension !== 'jsonl') {
92+ toastr.warning(t`Only .jsonl files are supported for chat imports.`);
93+ return;
94+ }
95+
96+ const context = SillyTavern.getContext();
97+
98+ const formData = new FormData();
99+ formData.set('file_type', extension);
100+ formData.set('avatar', file);
101+ formData.set('avatar_url', context.characters[context.characterId]?.avatar || '');
102+ formData.set('user_name', context.name1);
103+ formData.set('character_name', context.name2);
104+
105+ const importFn = context.groupId ? importGroupChat : importCharacterChat;
106+ const result = await importFn(formData, { refresh: false });
107+
108+ if (result.length === 0) {
109+ toastr.error(t`Failed to import chat backup, try again later.`);
110+ return;
111+ }
112+
113+ toastr.success(`Chat imported: ${result.join(', ')}`);
114+ await displayPastChats(result);
115+ }
116+
117+ /**
118+ * Delete a backup file.
119+ * @param {string} name File name of the backup to delete.
120+ * @returns {Promise<boolean>} True if deleted, false otherwise.
121+ */
122+ async deleteBackup(name) {
123+ const confirm = await Popup.show.confirm(t`Are you sure?`);
124+ if (!confirm) {
125+ return false;
126+ }
127+
128+ const response = await fetch('/api/backups/chat/delete', {
129+ method: 'POST',
130+ headers: getRequestHeaders(),
131+ body: JSON.stringify({ name: name }),
132+ });
133+
134+ if (!response.ok) {
135+ toastr.error(t`Failed to delete backup, try again later.`);
136+ console.error('Failed to delete chat backup:', response.statusText);
137+ return false;
138+ }
139+
140+ toastr.success(t`Backup deleted successfully.`);
141+ return true;
142+ }
143+
144+ /**
145+ * Load backups and populate the list element.
146+ * @param {AbortSignal} signal Signal to abort loading.
147+ * @returns {Promise<void>}
148+ */
149+ async loadBackupsIntoList(signal) {
150+ if (!this.#backupsListElement) {
151+ return;
152+ }
153+
154+ this.#backupsListElement.innerHTML = '';
155+
156+ const response = await fetch('/api/backups/chat/get', {
157+ method: 'POST',
158+ headers: getRequestHeaders(),
159+ signal,
160+ });
161+
162+ if (!response.ok) {
163+ console.error('Failed to load chat backups list:', response.statusText);
164+ return;
165+ }
166+
167+ /** @type {import('../../src/endpoints/chats.js').ChatInfo[]} */
168+ const backupsList = await response.json();
169+
170+ for (const backup of backupsList.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)))) {
171+ const listItem = document.createElement('div');
172+ listItem.classList.add('chatBackupsListItem');
173+
174+ const backupName = document.createElement('div');
175+ backupName.textContent = backup.file_name;
176+ backupName.classList.add('chatBackupsListItemName');
177+
178+ const backupInfo = document.createElement('div');
179+ backupInfo.classList.add('chatBackupsListItemInfo');
180+ backupInfo.textContent = `${timestampToMoment(backup.last_mes).format('lll')} (${backup.file_size}, ${backup.chat_items} 💬)`;
181+
182+ const actionsList = document.createElement('div');
183+ actionsList.classList.add('chatBackupsListItemActions');
184+
185+ const viewButton = document.createElement('div');
186+ viewButton.classList.add('right_menu_button', 'fa-solid', 'fa-eye');
187+ viewButton.title = t`View backup`;
188+ viewButton.addEventListener('click', async () => {
189+ await this.viewBackup(backup.file_name);
190+ });
191+
192+ const restoreButton = document.createElement('div');
193+ restoreButton.classList.add('right_menu_button', 'fa-solid', 'fa-rotate-left');
194+ restoreButton.title = t`Restore backup`;
195+ restoreButton.addEventListener('click', async () => {
196+ await this.restoreBackup(backup.file_name);
197+ });
198+
199+ const deleteButton = document.createElement('div');
200+ deleteButton.classList.add('right_menu_button', 'fa-solid', 'fa-trash');
201+ deleteButton.title = t`Delete backup`;
202+ deleteButton.addEventListener('click',async () => {
203+ const isDeleted = await this.deleteBackup(backup.file_name);
204+ if (isDeleted) {
205+ listItem.remove();
206+ }
207+ });
208+
209+ actionsList.appendChild(viewButton);
210+ actionsList.appendChild(restoreButton);
211+ actionsList.appendChild(deleteButton);
212+
213+ listItem.appendChild(backupName);
214+ listItem.appendChild(backupInfo);
215+ listItem.appendChild(actionsList);
216+
217+ this.#backupsListElement.appendChild(listItem);
218+ }
219+ }
220+
221+ closeBackups() {
222+ if (!this.#isOpen) {
223+ return;
224+ }
225+
226+ this.#isOpen = false;
227+ if (this.#buttonChevronIcon) {
228+ this.#buttonChevronIcon.classList.remove('fa-chevron-up');
229+ this.#buttonChevronIcon.classList.add('fa-chevron-down');
230+ }
231+ if (this.#backupsListElement) {
232+ this.#backupsListElement.classList.remove('open');
233+ this.#backupsListElement.innerHTML = '';
234+ }
235+ if (this.#loadingAbortController) {
236+ this.#loadingAbortController.abort();
237+ this.#loadingAbortController = null;
238+ }
239+ }
240+
241+ openBackups() {
242+ if (this.#isOpen) {
243+ return;
244+ }
245+
246+ this.#isOpen = true;
247+ if (this.#buttonChevronIcon) {
248+ this.#buttonChevronIcon.classList.remove('fa-chevron-down');
249+ this.#buttonChevronIcon.classList.add('fa-chevron-up');
250+ }
251+ if (this.#backupsListElement) {
252+ this.#backupsListElement.classList.add('open');
253+ }
254+ if (this.#loadingAbortController) {
255+ this.#loadingAbortController.abort();
256+ this.#loadingAbortController = null;
257+ }
258+
259+ this.#loadingAbortController = new AbortController();
260+ this.loadBackupsIntoList(this.#loadingAbortController.signal);
261+ }
262+
263+ renderButton() {
264+ if (this.#buttonElement) {
265+ return;
266+ }
267+
268+ const sibling = document.getElementById('select_chat_search');
269+ if (!sibling) {
270+ console.error('Could not find sibling element for BackupsBrowser button');
271+ return;
272+ }
273+
274+ const button = document.createElement('button');
275+ button.classList.add('menu_button', 'menu_button_icon');
276+
277+ const buttonIcon = document.createElement('i');
278+ buttonIcon.classList.add('fa-solid', 'fa-box-open');
279+
280+ const buttonText = document.createElement('span');
281+ buttonText.textContent = t`Backups`;
282+ buttonText.title = t`Browse chat backups`;
283+
284+ const chevronIcon = document.createElement('i');
285+ chevronIcon.classList.add('fa-solid', 'fa-chevron-down', 'fa-sm');
286+
287+ button.appendChild(buttonIcon);
288+ button.appendChild(buttonText);
289+ button.appendChild(chevronIcon);
290+
291+ button.addEventListener('click', () => {
292+ if (this.#isOpen) {
293+ this.closeBackups();
294+ } else {
295+ this.openBackups();
296+ }
297+ });
298+
299+ sibling.parentNode.insertBefore(button, sibling);
300+
301+ this.#buttonElement = button;
302+ this.#buttonChevronIcon = chevronIcon;
303+ }
304+
305+ renderBackupsList() {
306+ if (this.#backupsListElement) {
307+ return;
308+ }
309+
310+ const sibling = document.getElementById('select_chat_div');
311+ if (!sibling) {
312+ console.error('Could not find sibling element for BackupsBrowser list');
313+ return;
314+ }
315+
316+ const list = document.createElement('div');
317+ list.classList.add('chatBackupsList');
318+
319+ sibling.parentNode.insertBefore(list, sibling);
320+ this.#backupsListElement = list;
321+ }
322+}
323+
324+const backupsBrowser = new BackupsBrowser();
325+
326+export function addChatBackupsBrowser() {
327+ backupsBrowser.renderButton();
328+ backupsBrowser.renderBackupsList();
329+
330+ // Refresh the backups list if it's already open
331+ if (backupsBrowser.isOpen) {
332+ backupsBrowser.closeBackups();
333+ backupsBrowser.openBackups();
334+ }
335+}
public/scripts/group-chats.js+2 -0
@@ -2308,6 +2308,8 @@ export async function importGroupChat(formData, { refresh = true } = {}) {
23082308 await displayPastChats();
23092309 }
23102310 }
2311+
2312+ return [data.res];
23112313 }
23122314
23132315 return data?.fileNames || [];
public/style.css+1 -0
@@ -14,6 +14,7 @@
1414@import url(css/data-maid.css);
1515@import url(css/secrets.css);
1616@import url(css/backgrounds.css);
17+@import url(css/chat-backups.css);
1718
1819:root {
1920 interpolate-size: allow-keywords;
src/endpoints/backups.js+75 -0
@@ -0,0 +1,75 @@
1+import express from 'express';
2+import fs, { promises as fsPromises } from 'node:fs';
3+import path from 'node:path';
4+import sanitize from 'sanitize-filename';
5+import { CHAT_BACKUPS_PREFIX, getChatInfo } from './chats.js';
6+
7+export const router = express.Router();
8+
9+router.post('/chat/get', async (request, response) => {
10+ try {
11+ const backupModels = [];
12+ const backupFiles = await fsPromises
13+ .readdir(request.user.directories.backups, { withFileTypes: true })
14+ .then(d => d .filter(d => d.isFile() && path.extname(d.name) === '.jsonl' && d.name.startsWith(CHAT_BACKUPS_PREFIX)).map(d => d.name));
15+
16+ for (const name of backupFiles) {
17+ const filePath = path.join(request.user.directories.backups, name);
18+ const info = await getChatInfo(filePath);
19+ if (!info || !info.file_name) {
20+ continue;
21+ }
22+ backupModels.push(info);
23+ }
24+
25+ return response.json(backupModels);
26+ } catch (error) {
27+ console.error(error);
28+ return response.sendStatus(500);
29+ }
30+});
31+
32+router.post('/chat/delete', async (request, response) => {
33+ try {
34+ const { name } = request.body;
35+ const filePath = path.join(request.user.directories.backups, sanitize(name));
36+
37+ if (!path.parse(filePath).base.startsWith(CHAT_BACKUPS_PREFIX)) {
38+ console.warn('Attempt to delete non-chat backup file:', name);
39+ return response.sendStatus(400);
40+ }
41+
42+ if (!fs.existsSync(filePath)) {
43+ return response.sendStatus(404);
44+ }
45+
46+ await fsPromises.unlink(filePath);
47+ return response.sendStatus(200);
48+ }
49+ catch (error) {
50+ console.error(error);
51+ return response.sendStatus(500);
52+ }
53+});
54+
55+router.post('/chat/download', async (request, response) => {
56+ try {
57+ const { name } = request.body;
58+ const filePath = path.join(request.user.directories.backups, sanitize(name));
59+
60+ if (!path.parse(filePath).base.startsWith(CHAT_BACKUPS_PREFIX)) {
61+ console.warn('Attempt to download non-chat backup file:', name);
62+ return response.sendStatus(400);
63+ }
64+
65+ if (!fs.existsSync(filePath)) {
66+ return response.sendStatus(404);
67+ }
68+
69+ return response.download(filePath);
70+ }
71+ catch (error) {
72+ console.error(error);
73+ return response.sendStatus(500);
74+ }
75+});
src/endpoints/chats.js+2 -3
@@ -365,7 +365,7 @@ async function checkChatIntegrity(filePath, integritySlug) {
365365 * @typedef {Object} ChatInfo
366366 * @property {string} [file_id] - The name of the chat file (without extension)
367367 * @property {string} [file_name] - The name of the chat file (with extension)
368368 * @property {string} [file_size] - The size of the chat file in a human-readable format
369369 * @property {number} [chat_items] - The number of chat items in the file
370370 * @property {string} [mes] - The last message in the chat
371371 * @property {number} [last_mes] - The timestamp of the last message
@@ -383,12 +383,11 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
383383 return new Promise(async (res) => {
384384 const parsedPath = path.parse(pathToFile);
385385 const stats = await fs.promises.stat(pathToFile);
386- const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`;
387386
388387 const chatData = {
389388 file_id: parsedPath.name,
390389 file_name: parsedPath.base,
391390 file_size: fileSizeInKBformatBytes(stats.size),
392391 chat_items: 0,
393392 mes: '[The chat is empty]',
394393 last_mes: stats.mtimeMs,
src/server-startup.js+2 -0
@@ -47,6 +47,7 @@ import { router as speechRouter } from './endpoints/speech.js';
4747import { router as azureRouter } from './endpoints/azure.js';
4848import { router as minimaxRouter } from './endpoints/minimax.js';
4949import { router as dataMaidRouter } from './endpoints/data-maid.js';
50+import { router as backupsRouter } from './endpoints/backups.js';
5051
5152/**
5253 * @typedef {object} ServerStartupResult
@@ -175,6 +176,7 @@ export function setupPrivateEndpoints(app) {
175176 app.use('/api/azure', azureRouter);
176177 app.use('/api/minimax', minimaxRouter);
177178 app.use('/api/data-maid', dataMaidRouter);
179+ app.use('/api/backups', backupsRouter);
178180}
179181
180182/**
src/util.js+3 -8
@@ -190,16 +190,11 @@ export function getHexString(length) {
190190
191191/**
192192 * Formats a byte size into a human-readable string with units
193193 * @param {number} bytesnumBytes - The size in bytes to format
194194 * @returns {string} The formatted string (e.g., "1.5 MB")
195195 */
196196export function formatBytes(bytesnumBytes) {
197- if (bytes === 0) return '0 B';
197+ return bytes.format(numBytes) ?? '';
198-
199- const k = 1024;
200- const sizes = ['B', 'KB', 'MB', 'GB'];
201- const i = Math.floor(Math.log(bytes) / Math.log(k));
202- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
203198}
204199
205200/**