// Move chat functions here from script.js (eventually) import { addCopyToCodeBlocks, appendMediaToMessage, callPopup, chat, eventSource, event_types, getCurrentChatId, getRequestHeaders, hideSwipeButtons, name2, saveChatDebounced, showSwipeButtons, } from '../script.js'; import { extractTextFromHTML, extractTextFromMarkdown, extractTextFromPDF, getBase64Async, getStringHash, humanFileSize, saveBase64AsFile, } from './utils.js'; const fileSizeLimit = 1024 * 1024 * 10; // 10 MB const converters = { 'application/pdf': extractTextFromPDF, 'text/html': extractTextFromHTML, 'text/markdown': extractTextFromMarkdown, }; function isConvertible(type) { return Object.keys(converters).includes(type); } /** * Mark message as hidden (system message). * @param {number} messageId Message ID * @param {JQuery} messageBlock Message UI element * @returns */ export async function hideChatMessage(messageId, messageBlock) { const chatId = getCurrentChatId(); if (!chatId || isNaN(messageId)) return; const message = chat[messageId]; if (!message) return; message.is_system = true; messageBlock.attr('is_system', String(true)); // Reload swipes. Useful when a last message is hidden. hideSwipeButtons(); showSwipeButtons(); saveChatDebounced(); } /** * Mark message as visible (non-system message). * @param {number} messageId Message ID * @param {JQuery} messageBlock Message UI element * @returns */ export async function unhideChatMessage(messageId, messageBlock) { const chatId = getCurrentChatId(); if (!chatId || isNaN(messageId)) return; const message = chat[messageId]; if (!message) return; message.is_system = false; messageBlock.attr('is_system', String(false)); // Reload swipes. Useful when a last message is hidden. hideSwipeButtons(); showSwipeButtons(); saveChatDebounced(); } /** * Adds a file attachment to the message. * @param {object} message Message object * @returns {Promise} A promise that resolves when file is uploaded. */ export async function populateFileAttachment(message, inputId = 'file_form_input') { try { if (!message) return; if (!message.extra) message.extra = {}; const fileInput = document.getElementById(inputId); if (!(fileInput instanceof HTMLInputElement)) return; const file = fileInput.files[0]; if (!file) return; const fileBase64 = await getBase64Async(file); let base64Data = fileBase64.split(',')[1]; // If file is image if (file.type.startsWith('image/')) { const extension = file.type.split('/')[1]; const imageUrl = await saveBase64AsFile(base64Data, name2, file.name, extension); message.extra.image = imageUrl; message.extra.inline_image = true; } else { const slug = getStringHash(file.name); const uniqueFileName = `${Date.now()}_${slug}.txt`; if (isConvertible(file.type)) { try { const converter = converters[file.type]; const fileText = await converter(file); base64Data = window.btoa(unescape(encodeURIComponent(fileText))); } catch (error) { toastr.error(String(error), 'Could not convert file'); console.error('Could not convert file', error); } } const fileUrl = await uploadFileAttachment(uniqueFileName, base64Data); if (!fileUrl) { return; } message.extra.file = { url: fileUrl, size: file.size, name: file.name, }; } } catch (error) { console.error('Could not upload file', error); } finally { $('#file_form').trigger('reset'); } } /** * Uploads file to the server. * @param {string} fileName * @param {string} base64Data * @returns {Promise} File URL */ export async function uploadFileAttachment(fileName, base64Data) { try { const result = await fetch('/api/files/upload', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify({ name: fileName, data: base64Data, }), }); if (!result.ok) { const error = await result.text(); throw new Error(error); } const responseData = await result.json(); return responseData.path; } catch (error) { toastr.error(String(error), 'Could not upload file'); console.error('Could not upload file', error); } } /** * Downloads file from the server. * @param {string} url File URL * @returns {Promise} File text */ export async function getFileAttachment(url) { try { const result = await fetch(url, { method: 'GET', cache: 'force-cache', headers: getRequestHeaders(), }); if (!result.ok) { const error = await result.text(); throw new Error(error); } const text = await result.text(); return text; } catch (error) { toastr.error(error, 'Could not download file'); console.error('Could not download file', error); } } /** * Validates file to make sure it is not binary or not image. * @param {File} file File object * @returns {Promise} True if file is valid, false otherwise. */ async function validateFile(file) { const fileText = await file.text(); const isImage = file.type.startsWith('image/'); const isBinary = /^[\x00-\x08\x0E-\x1F\x7F-\xFF]*$/.test(fileText); if (!isImage && file.size > fileSizeLimit) { toastr.error(`File is too big. Maximum size is ${humanFileSize(fileSizeLimit)}.`); return false; } // If file is binary if (isBinary && !isImage && !isConvertible(file.type)) { toastr.error('Binary files are not supported. Select a text file or image.'); return false; } return true; } export function hasPendingFileAttachment() { const fileInput = document.getElementById('file_form_input'); if (!(fileInput instanceof HTMLInputElement)) return false; const file = fileInput.files[0]; return !!file; } /** * Displays file information in the message sending form. * @returns {Promise} */ async function onFileAttach() { const fileInput = document.getElementById('file_form_input'); if (!(fileInput instanceof HTMLInputElement)) return; const file = fileInput.files[0]; if (!file) return; const isValid = await validateFile(file); // If file is binary if (!isValid) { $('#file_form').trigger('reset'); return; } $('#file_form .file_name').text(file.name); $('#file_form .file_size').text(humanFileSize(file.size)); $('#file_form').removeClass('displayNone'); // Reset form on chat change eventSource.once(event_types.CHAT_CHANGED, () => { $('#file_form').trigger('reset'); }); } /** * Deletes file from message. * @param {number} messageId Message ID */ async function deleteMessageFile(messageId) { const confirm = await callPopup('Are you sure you want to delete this file?', 'confirm'); if (!confirm) { console.debug('Delete file cancelled'); return; } const message = chat[messageId]; if (!message?.extra?.file) { console.debug('Message has no file'); return; } delete message.extra.file; $(`.mes[mesid="${messageId}"] .mes_file_container`).remove(); saveChatDebounced(); } /** * Opens file from message in a modal. * @param {number} messageId Message ID */ async function viewMessageFile(messageId) { const messageFile = chat[messageId]?.extra?.file; if (!messageFile) { console.debug('Message has no file or it is empty'); return; } const fileText = messageFile.text || (await getFileAttachment(messageFile.url)); const modalTemplate = $('
'); modalTemplate.find('code').addClass('txt').text(fileText); modalTemplate.addClass('file_modal'); addCopyToCodeBlocks(modalTemplate); callPopup(modalTemplate, 'text', '', { wide: true, large: true }); } /** * Inserts a file embed into the message. * @param {number} messageId * @param {JQuery} messageBlock * @returns {Promise} */ function embedMessageFile(messageId, messageBlock) { const message = chat[messageId]; if (!message) { console.warn('Failed to find message with id', messageId); return; } $('#embed_file_input') .off('change') .on('change', parseAndUploadEmbed) .trigger('click'); async function parseAndUploadEmbed(e) { const file = e.target.files[0]; if (!file) return; const isValid = await validateFile(file); if (!isValid) { $('#file_form').trigger('reset'); return; } await populateFileAttachment(message, 'embed_file_input'); appendMediaToMessage(message, messageBlock); saveChatDebounced(); } } jQuery(function () { $(document).on('click', '.mes_hide', async function () { const messageBlock = $(this).closest('.mes'); const messageId = Number(messageBlock.attr('mesid')); await hideChatMessage(messageId, messageBlock); }); $(document).on('click', '.mes_unhide', async function () { const messageBlock = $(this).closest('.mes'); const messageId = Number(messageBlock.attr('mesid')); await unhideChatMessage(messageId, messageBlock); }); $(document).on('click', '.mes_file_delete', async function () { const messageBlock = $(this).closest('.mes'); const messageId = Number(messageBlock.attr('mesid')); await deleteMessageFile(messageId); }); $(document).on('click', '.mes_file_open', async function () { const messageBlock = $(this).closest('.mes'); const messageId = Number(messageBlock.attr('mesid')); await viewMessageFile(messageId); }); // Do not change. #attachFile is added by extension. $(document).on('click', '#attachFile', function () { $('#file_form_input').trigger('click'); }); $(document).on('click', '.mes_embed', function () { const messageBlock = $(this).closest('.mes'); const messageId = Number(messageBlock.attr('mesid')); embedMessageFile(messageId, messageBlock); }); $(document).on('click', '.editor_maximize', function () { const broId = $(this).attr('data-for'); const bro = $(`#${broId}`); if (!bro.length) { console.error('Could not find editor with id', broId); return; } const wrapper = document.createElement('div'); wrapper.classList.add('height100p', 'wide100p', 'flex-container'); wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter'); const textarea = document.createElement('textarea'); textarea.value = String(bro.val()); textarea.classList.add('height100p', 'wide100p'); textarea.oninput = function () { bro.val(textarea.value).trigger('input'); }; wrapper.appendChild(textarea); callPopup(wrapper, 'text', '', { wide: true, large: true }); }); $('#file_form_input').on('change', onFileAttach); $('#file_form').on('reset', function () { $('#file_form').addClass('displayNone'); }); });