mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Add ability to attach files and images to messages
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
// Move chat functions here from script.js (eventually)
|
||||
|
||||
import {
|
||||
addCopyToCodeBlocks,
|
||||
appendMediaToMessage,
|
||||
callPopup,
|
||||
chat,
|
||||
eventSource,
|
||||
event_types,
|
||||
getCurrentChatId,
|
||||
hideSwipeButtons,
|
||||
name2,
|
||||
saveChatDebounced,
|
||||
showSwipeButtons,
|
||||
} from "../script.js";
|
||||
import { getBase64Async, humanFileSize, saveBase64AsFile } from "./utils.js";
|
||||
|
||||
const fileSizeLimit = 1024 * 1024 * 1; // 1 MB
|
||||
|
||||
/**
|
||||
* Mark message as hidden (system message).
|
||||
@@ -58,16 +67,222 @@ export async function unhideChatMessage(messageId, messageBlock) {
|
||||
saveChatDebounced();
|
||||
}
|
||||
|
||||
jQuery(function() {
|
||||
$(document).on('click', '.mes_hide', async function() {
|
||||
/**
|
||||
* Adds a file attachment to the message.
|
||||
* @param {object} message Message object
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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;
|
||||
|
||||
// If file is image
|
||||
if (file.type.startsWith('image/')) {
|
||||
const base64Img = await getBase64Async(file);
|
||||
const base64ImgData = base64Img.split(',')[1];
|
||||
const extension = file.type.split('/')[1];
|
||||
const imageUrl = await saveBase64AsFile(base64ImgData, name2, file.name, extension);
|
||||
message.extra.image = imageUrl;
|
||||
message.extra.inline_image = true;
|
||||
} else {
|
||||
const fileText = await file.text();
|
||||
message.extra.file = {
|
||||
text: fileText,
|
||||
size: file.size,
|
||||
name: file.name,
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Could not upload file', error);
|
||||
} finally {
|
||||
$('#file_form').trigger('reset');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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 messageText = chat[messageId]?.extra?.file?.text;
|
||||
|
||||
if (!messageText) {
|
||||
console.debug('Message has no file or it is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const modalTemplate = $('<div><pre><code></code></pre></div>');
|
||||
modalTemplate.find('code').addClass('txt').text(messageText);
|
||||
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() {
|
||||
$(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');
|
||||
});
|
||||
})
|
||||
|
@@ -1,9 +1,8 @@
|
||||
import { getBase64Async, saveBase64AsFile } from "../../utils.js";
|
||||
import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules } from "../../extensions.js";
|
||||
import { appendImageToMessage, callPopup, getRequestHeaders, saveSettingsDebounced, substituteParams } from "../../../script.js";
|
||||
import { callPopup, getRequestHeaders, saveSettingsDebounced, substituteParams } from "../../../script.js";
|
||||
import { getMessageTimeStamp } from "../../RossAscends-mods.js";
|
||||
import { SECRET_KEYS, secret_state } from "../../secrets.js";
|
||||
import { isImageInliningSupported } from "../../openai.js";
|
||||
import { getMultimodalCaption } from "../shared.js";
|
||||
export { MODULE_NAME };
|
||||
|
||||
@@ -118,7 +117,6 @@ async function sendCaptionedMessage(caption, image) {
|
||||
};
|
||||
context.chat.push(message);
|
||||
context.addOneMessage(message);
|
||||
await context.generate('caption');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,99 +253,22 @@ function onRefineModeInput() {
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
async function sendEmbeddedImage(e) {
|
||||
const file = e.target.files[0];
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = getContext();
|
||||
const fileData = await getBase64Async(file);
|
||||
const base64Format = fileData.split(',')[0].split(';')[0].split('/')[1];
|
||||
const base64Data = fileData.split(',')[1];
|
||||
const caption = await callPopup('<h3>Enter a comment or question (optional)</h3>', 'input', 'What is this?', { okButton: 'Send', rows: 2 });
|
||||
const imagePath = await saveBase64AsFile(base64Data, context.name2, '', base64Format);
|
||||
const message = {
|
||||
name: context.name1,
|
||||
is_user: true,
|
||||
send_date: getMessageTimeStamp(),
|
||||
mes: caption || `[${context.name1} sends ${context.name2} a picture]`,
|
||||
extra: {
|
||||
image: imagePath,
|
||||
inline_image: !!caption,
|
||||
title: caption || '',
|
||||
},
|
||||
};
|
||||
context.chat.push(message);
|
||||
context.addOneMessage(message);
|
||||
await context.generate('caption');
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
finally {
|
||||
e.target.form.reset();
|
||||
setImageIcon();
|
||||
}
|
||||
}
|
||||
|
||||
function onImageEmbedClicked() {
|
||||
const context = getContext();
|
||||
const messageElement = $(this).closest('.mes');
|
||||
const messageId = messageElement.attr('mesid');
|
||||
const message = context.chat[messageId];
|
||||
|
||||
if (!message) {
|
||||
console.warn('Failed to find message with id', messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
$('#embed_img_file')
|
||||
.off('change')
|
||||
.on('change', parseAndUploadEmbed)
|
||||
.trigger('click');
|
||||
|
||||
async function parseAndUploadEmbed(e) {
|
||||
const file = e.target.files[0];
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return;
|
||||
}
|
||||
const fileData = await getBase64Async(file);
|
||||
const base64Data = fileData.split(',')[1];
|
||||
const base64Format = fileData.split(',')[0].split(';')[0].split('/')[1];
|
||||
const imagePath = await saveBase64AsFile(base64Data, context.name2, '', base64Format);
|
||||
|
||||
if (!message.extra) {
|
||||
message.extra = {};
|
||||
}
|
||||
|
||||
message.extra.image = imagePath;
|
||||
message.extra.inline_image = true;
|
||||
message.extra.title = '';
|
||||
appendImageToMessage(message, messageElement);
|
||||
await context.saveChat();
|
||||
}
|
||||
}
|
||||
|
||||
jQuery(function () {
|
||||
function addSendPictureButton() {
|
||||
const sendButton = $(`
|
||||
<div id="send_picture" class="list-group-item flex-container flexGap5">
|
||||
<div class="fa-solid fa-image extensionsMenuExtensionButton"></div>
|
||||
Send a Picture
|
||||
Generate Caption
|
||||
</div>`);
|
||||
const attachFileButton = $(`
|
||||
<div id="attachFile" class="list-group-item flex-container flexGap5">
|
||||
<div class="fa-solid fa-paperclip extensionsMenuExtensionButton"></div>
|
||||
Attach a File
|
||||
</div>`);
|
||||
|
||||
$('#extensionsMenu').prepend(sendButton);
|
||||
$('#extensionsMenu').prepend(attachFileButton);
|
||||
$(sendButton).on('click', () => {
|
||||
if (isImageInliningSupported()) {
|
||||
console.log('Native image inlining is supported. Skipping captioning.');
|
||||
$('#embed_img_file').off('change').on('change', sendEmbeddedImage).trigger('click');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCaptionModule =
|
||||
(modules.includes('caption') && extension_settings.caption.source === 'extras') ||
|
||||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'openai' && secret_state[SECRET_KEYS.OPENAI]) ||
|
||||
@@ -365,11 +286,9 @@ jQuery(function () {
|
||||
}
|
||||
function addPictureSendForm() {
|
||||
const inputHtml = `<input id="img_file" type="file" hidden accept="image/*">`;
|
||||
const embedInputHtml = `<input id="embed_img_file" type="file" hidden accept="image/*">`;
|
||||
const imgForm = document.createElement('form');
|
||||
imgForm.id = 'img_form';
|
||||
$(imgForm).append(inputHtml);
|
||||
$(imgForm).append(embedInputHtml);
|
||||
$(imgForm).hide();
|
||||
$('#form_sheld').append(imgForm);
|
||||
$('#img_file').on('change', onSelectImage);
|
||||
@@ -472,5 +391,4 @@ jQuery(function () {
|
||||
extension_settings.caption.template = String($('#caption_template').val());
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$(document).on('click', '.mes_embed', onImageEmbedClicked);
|
||||
});
|
||||
|
@@ -365,7 +365,7 @@ async function sendUserMessageCallback(_, text) {
|
||||
|
||||
text = text.trim();
|
||||
const bias = extractMessageBias(text);
|
||||
sendMessageAsUser(text, bias);
|
||||
await sendMessageAsUser(text, bias);
|
||||
}
|
||||
|
||||
async function deleteMessagesByNameCallback(_, name) {
|
||||
|
@@ -512,6 +512,38 @@ export function trimToStartSentence(input) {
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
*
|
||||
* @param bytes Number of bytes.
|
||||
* @param si True to use metric (SI) units, aka powers of 1000. False to use
|
||||
* binary (IEC), aka powers of 1024.
|
||||
* @param dp Number of decimal places to display.
|
||||
*
|
||||
* @return Formatted string.
|
||||
*/
|
||||
export function humanFileSize(bytes, si = false, dp = 1) {
|
||||
const thresh = si ? 1000 : 1024;
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
const units = si
|
||||
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
let u = -1;
|
||||
const r = 10 ** dp;
|
||||
|
||||
do {
|
||||
bytes /= thresh;
|
||||
++u;
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
||||
|
||||
|
||||
return bytes.toFixed(dp) + ' ' + units[u];
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of occurrences of a character in a string.
|
||||
* @param {string} string The string to count occurrences in.
|
||||
|
Reference in New Issue
Block a user