Implement chat backups browse menu (#4862) * Implement chat backups browse menu * Unify bytes string formatting
Signed| @@ -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 | } | ||
| @@ -279,6 +279,7 @@ import { applyStreamFadeIn } from './scripts/util/stream-fadein.js'; | |||
| 279 | import { initDomHandlers } from './scripts/dom-handlers.js'; | 279 | import { initDomHandlers } from './scripts/dom-handlers.js'; |
| 280 | import { SimpleMutex } from './scripts/util/SimpleMutex.js'; | 280 | import { SimpleMutex } from './scripts/util/SimpleMutex.js'; |
| 281 | import { AudioPlayer } from './scripts/audio-player.js'; | 281 | import { AudioPlayer } from './scripts/audio-player.js'; |
| 282 | import { addChatBackupsBrowser } from './scripts/chat-backups.js'; | ||
| 282 | 283 | ||
| 283 | // API OBJECT FOR EXTERNAL WIRING | 284 | // API OBJECT FOR EXTERNAL WIRING |
| 284 | globalThis.SillyTavern = { | 285 | globalThis.SillyTavern = { |
| @@ -8093,7 +8094,7 @@ export async function displayPastChats(hightlightNames = []) { | |||
| 8093 | }); | 8094 | }); |
| 8094 | 8095 | ||
| 8095 | // Define the search input listener | 8096 | // 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 | } |
| 8107 | 8110 | ||
| 8108 | async function displayChats(searchQuery, currentChat, displayName, avatarImg, selected_group, highlightNames) { | 8111 | async 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 import | 8935 | * @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 | */ |
| 8935 | async function importCharacterChat(formData, { refresh = true } = {}) { | 8938 | export 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, |
| @@ -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 | } | ||
| @@ -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 | } |
| 2312 | 2314 | ||
| 2313 | return data?.fileNames || []; | 2315 | return data?.fileNames || []; |
| @@ -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); | ||
| 17 | 18 | ||
| 18 | :root { | 19 | :root { |
| 19 | interpolate-size: allow-keywords; | 20 | interpolate-size: allow-keywords; |
| @@ -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 | }); | ||
| @@ -365,7 +365,7 @@ async function checkChatIntegrity(filePath, integritySlug) { | |||
| 365 | * @typedef {Object} ChatInfo | 365 | * @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 file | 368 | * @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 file | 369 | * @property {number} [chat_items] - The number of chat items in the file |
| 370 | * @property {string} [mes] - The last message in the chat | 370 | * @property {string} [mes] - The last message in the chat |
| 371 | * @property {number} [last_mes] - The timestamp of the last message | 371 | * @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`; | ||
| 387 | 386 | ||
| 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, |
| @@ -47,6 +47,7 @@ import { router as speechRouter } from './endpoints/speech.js'; | |||
| 47 | import { router as azureRouter } from './endpoints/azure.js'; | 47 | import { router as azureRouter } from './endpoints/azure.js'; |
| 48 | import { router as minimaxRouter } from './endpoints/minimax.js'; | 48 | import { router as minimaxRouter } from './endpoints/minimax.js'; |
| 49 | import { router as dataMaidRouter } from './endpoints/data-maid.js'; | 49 | import { router as dataMaidRouter } from './endpoints/data-maid.js'; |
| 50 | import { router as backupsRouter } from './endpoints/backups.js'; | ||
| 50 | 51 | ||
| 51 | /** | 52 | /** |
| 52 | * @typedef {object} ServerStartupResult | 53 | * @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 | } |
| 179 | 181 | ||
| 180 | /** | 182 | /** |
| @@ -190,16 +190,11 @@ export function getHexString(length) { | |||
| 190 | 190 | ||
| 191 | /** | 191 | /** |
| 192 | * Formats a byte size into a human-readable string with units | 192 | * Formats a byte size into a human-readable string with units |
| 193 | * @param {number} bytes - The size in bytes to format | 193 | * @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 | */ |
| 196 | export function formatBytes(bytes) { | 196 | export 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 | } |
| 204 | 199 | ||
| 205 | /** | 200 | /** |