SillyTavern/public/scripts/chats.js
2023-12-02 13:04:51 -05:00

385 lines
11 KiB
JavaScript

// 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<Element>} 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<Element>} 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<void>} 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<string>} File URL
*/
export async function uploadFileAttachment(fileName, base64Data) {
try {
const result = await fetch('/api/file/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.replace(/\\/g, '/');
} 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<string>} 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<boolean>} 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<void>}
*/
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 = $('<div><pre><code></code></pre></div>');
modalTemplate.find('code').addClass('txt').text(fileText);
modalTemplate.addClass('file_modal');
addCopyToCodeBlocks(modalTemplate);
callPopup(modalTemplate, 'text');
}
/**
* Inserts a file embed into the message.
* @param {number} messageId
* @param {JQuery<HTMLElement>} messageBlock
* @returns {Promise<void>}
*/
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);
});
$('#file_form_input').on('change', onFileAttach);
$('#file_form').on('reset', function () {
$('#file_form').addClass('displayNone');
});
})