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';
279import { initDomHandlers } from './scripts/dom-handlers.js';279import { initDomHandlers } from './scripts/dom-handlers.js';
280import { SimpleMutex } from './scripts/util/SimpleMutex.js';280import { SimpleMutex } from './scripts/util/SimpleMutex.js';
281import { AudioPlayer } from './scripts/audio-player.js';281import { AudioPlayer } from './scripts/audio-player.js';
282import { addChatBackupsBrowser } from './scripts/chat-backups.js';
282283
283// API OBJECT FOR EXTERNAL WIRING284// API OBJECT FOR EXTERNAL WIRING
284globalThis.SillyTavern = {285globalThis.SillyTavern = {
@@ -8093,7 +8094,7 @@ export async function displayPastChats(hightlightNames = []) {
8093 });8094 });
80948095
8095 // Define the search input listener8096 // Define the search input listener
8096 $('#select_chat_search').on('input', function () {8097 $('#select_chat_search').off('input').on('input', function () {
8097 const searchQuery = $(this).val();8098 const searchQuery = $(this).val();
8098 debouncedDisplay(searchQuery);8099 debouncedDisplay(searchQuery);
8099 });8100 });
@@ -8103,6 +8104,8 @@ export async function displayPastChats(hightlightNames = []) {
8103 const textSearchElement = $('#select_chat_search');8104 const textSearchElement = $('#select_chat_search');
8104 textSearchElement.trigger('click').trigger('focus').trigger('select');8105 textSearchElement.trigger('click').trigger('focus').trigger('select');
8105 }, 200);8106 }, 200);
8107
8108 addChatBackupsBrowser();
8106}8109}
81078110
8108async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {8111async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) {
@@ -8932,7 +8935,7 @@ export async function saveChatConditional() {
8932 * @param {boolean} [options.refresh] Whether to refresh the group chat list after import8935 * @param {boolean} [options.refresh] Whether to refresh the group chat list after import
8933 * @returns {Promise<string[]>} List of imported file names.8936 * @returns {Promise<string[]>} List of imported file names.
8934 */8937 */
8935async function importCharacterChat(formData, { refresh = true } = {}) {8938export async function importCharacterChat(formData, { refresh = true } = {}) {
8936 const fetchResult = await fetch('/api/chats/import', {8939 const fetchResult = await fetch('/api/chats/import', {
8937 method: 'POST',8940 method: 'POST',
8938 body: formData,8941 body: formData,
public/scripts/chat-backups.js+335 -0
@@ -0,0 +1,335 @@
1import { t } from './i18n.js';
2import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
3import { getFileExtension, sortMoments, timestampToMoment } from './utils.js';
4import { displayPastChats, getRequestHeaders, importCharacterChat } from '/script.js';
5import { importGroupChat } from './group-chats.js';
6
7class 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
324const backupsBrowser = new BackupsBrowser();
325
326export 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 } = {}) {
2308 await displayPastChats();2308 await displayPastChats();
2309 }2309 }
2310 }2310 }
2311
2312 return [data.res];
2311 }2313 }
23122314
2313 return data?.fileNames || [];2315 return data?.fileNames || [];
public/style.css+1 -0
@@ -14,6 +14,7 @@
14@import url(css/data-maid.css);14@import url(css/data-maid.css);
15@import url(css/secrets.css);15@import url(css/secrets.css);
16@import url(css/backgrounds.css);16@import url(css/backgrounds.css);
17@import url(css/chat-backups.css);
1718
18:root {19:root {
19 interpolate-size: allow-keywords;20 interpolate-size: allow-keywords;
src/endpoints/backups.js+75 -0
@@ -0,0 +1,75 @@
1import express from 'express';
2import fs, { promises as fsPromises } from 'node:fs';
3import path from 'node:path';
4import sanitize from 'sanitize-filename';
5import { CHAT_BACKUPS_PREFIX, getChatInfo } from './chats.js';
6
7export const router = express.Router();
8
9router.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
32router.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
55router.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) {
365 * @typedef {Object} ChatInfo365 * @typedef {Object} ChatInfo
366 * @property {string} [file_id] - The name of the chat file (without extension)366 * @property {string} [file_id] - The name of the chat file (without extension)
367 * @property {string} [file_name] - The name of the chat file (with extension)367 * @property {string} [file_name] - The name of the chat file (with extension)
368 * @property {string} [file_size] - The size of the chat file368 * @property {string} [file_size] - The size of the chat file in a human-readable format
369 * @property {number} [chat_items] - The number of chat items in the file369 * @property {number} [chat_items] - The number of chat items in the file
370 * @property {string} [mes] - The last message in the chat370 * @property {string} [mes] - The last message in the chat
371 * @property {number} [last_mes] - The timestamp of the last message371 * @property {number} [last_mes] - The timestamp of the last message
@@ -383,12 +383,11 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata
383 return new Promise(async (res) => {383 return new Promise(async (res) => {
384 const parsedPath = path.parse(pathToFile);384 const parsedPath = path.parse(pathToFile);
385 const stats = await fs.promises.stat(pathToFile);385 const stats = await fs.promises.stat(pathToFile);
386 const fileSizeInKB = `${(stats.size / 1024).toFixed(2)}kb`;
387386
388 const chatData = {387 const chatData = {
389 file_id: parsedPath.name,388 file_id: parsedPath.name,
390 file_name: parsedPath.base,389 file_name: parsedPath.base,
391 file_size: fileSizeInKB,390 file_size: formatBytes(stats.size),
392 chat_items: 0,391 chat_items: 0,
393 mes: '[The chat is empty]',392 mes: '[The chat is empty]',
394 last_mes: stats.mtimeMs,393 last_mes: stats.mtimeMs,
src/server-startup.js+2 -0
@@ -47,6 +47,7 @@ import { router as speechRouter } from './endpoints/speech.js';
47import { router as azureRouter } from './endpoints/azure.js';47import { router as azureRouter } from './endpoints/azure.js';
48import { router as minimaxRouter } from './endpoints/minimax.js';48import { router as minimaxRouter } from './endpoints/minimax.js';
49import { router as dataMaidRouter } from './endpoints/data-maid.js';49import { router as dataMaidRouter } from './endpoints/data-maid.js';
50import { router as backupsRouter } from './endpoints/backups.js';
5051
51/**52/**
52 * @typedef {object} ServerStartupResult53 * @typedef {object} ServerStartupResult
@@ -175,6 +176,7 @@ export function setupPrivateEndpoints(app) {
175 app.use('/api/azure', azureRouter);176 app.use('/api/azure', azureRouter);
176 app.use('/api/minimax', minimaxRouter);177 app.use('/api/minimax', minimaxRouter);
177 app.use('/api/data-maid', dataMaidRouter);178 app.use('/api/data-maid', dataMaidRouter);
179 app.use('/api/backups', backupsRouter);
178}180}
179181
180/**182/**
src/util.js+3 -8
@@ -190,16 +190,11 @@ export function getHexString(length) {
190190
191/**191/**
192 * Formats a byte size into a human-readable string with units192 * Formats a byte size into a human-readable string with units
193 * @param {number} bytes - The size in bytes to format193 * @param {number} numBytes - The size in bytes to format
194 * @returns {string} The formatted string (e.g., "1.5 MB")194 * @returns {string} The formatted string (e.g., "1.5 MB")
195 */195 */
196export function formatBytes(bytes) {196export function formatBytes(numBytes) {
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];
203}198}
204199
205/**200/**