Compare commits

...

9 Commits

Author SHA1 Message Date
Cohee ef5d505de3 Merge branch 'staging' into neo-server 2024-04-21 18:28:56 +03:00
Cohee 842b463e60 System same as user for Llama 3 2024-04-21 18:28:44 +03:00
Cohee 5992c34fb5 Add DB attachment editor 2024-04-21 18:23:41 +03:00
Cohee bae74fbbd7 Add notepad data bank file creator 2024-04-21 18:11:03 +03:00
Cohee 4264d170e2 Add support for Office plugin 2024-04-21 16:27:44 +03:00
Cohee 274359d1ec Move prompt manager actions row to the top 2024-04-21 14:48:21 +03:00
Cohee dac89a87b7 Fix new characters highlight 2024-04-21 14:20:24 +03:00
Cohee fb5d998cd0 Allow all macro in story strings 2024-04-21 14:06:33 +03:00
Cohee ca89be8930 Add experimental setting for file translation 2024-04-21 03:24:01 +03:00
11 changed files with 257 additions and 16 deletions

View File

@ -18,7 +18,7 @@
"input_suffix": "<|eot_id|>",
"system_suffix": "<|eot_id|>",
"user_alignment_message": "",
"system_same_as_user": false,
"system_same_as_user": true,
"last_system_sequence": "",
"name": "Llama 3 Instruct"
}

View File

@ -6773,8 +6773,9 @@ function select_rm_info(type, charId, previousCharId = null) {
importFlashTimeout = setTimeout(function () {
if (type === 'char_import' || type === 'char_create') {
// Find the page at which the character is located
const avatarFileName = `${charId}.png`;
const charData = getEntitiesList({ doFilter: true });
const charIndex = charData.findIndex((x) => x?.item?.avatar?.startsWith(charId));
const charIndex = charData.findIndex((x) => x?.item?.avatar?.startsWith(avatarFileName));
if (charIndex === -1) {
console.log(`Could not find character ${charId} in the list`);
@ -6784,7 +6785,7 @@ function select_rm_info(type, charId, previousCharId = null) {
try {
const perPage = Number(localStorage.getItem('Characters_PerPage')) || per_page_default;
const page = Math.floor(charIndex / perPage) + 1;
const selector = `#rm_print_characters_block [title^="${charId}"]`;
const selector = `#rm_print_characters_block [title*="${avatarFileName}"]`;
$('#rm_print_characters_pagination').pagination('go', page);
waitUntilCondition(() => document.querySelector(selector) !== null).then(() => {

View File

@ -1398,7 +1398,8 @@ class PromptManager {
`;
const rangeBlockDiv = promptManagerDiv.querySelector('.range-block');
rangeBlockDiv.insertAdjacentHTML('beforeend', footerHtml);
const headerDiv = promptManagerDiv.querySelector('.completion_prompt_manager_header');
headerDiv.insertAdjacentHTML('afterend', footerHtml);
rangeBlockDiv.querySelector('#prompt-manager-reset-character').addEventListener('click', this.handleCharacterReset);
const footerDiv = rangeBlockDiv.querySelector(`.${this.configuration.prefix}prompt_manager_footer`);
@ -1427,7 +1428,12 @@ class PromptManager {
rangeBlockDiv.insertAdjacentHTML('beforeend', exportPopup);
let exportPopper = Popper.createPopper(
// Destroy previous popper instance if it exists
if (this.exportPopper) {
this.exportPopper.destroy();
}
this.exportPopper = Popper.createPopper(
document.getElementById('prompt-manager-export'),
document.getElementById('prompt-manager-export-format-popup'),
{ placement: 'bottom' },
@ -1440,7 +1446,7 @@ class PromptManager {
if (show) popup.removeAttribute('data-show');
else popup.setAttribute('data-show', '');
exportPopper.update();
this.exportPopper.update();
};
footerDiv.querySelector('#prompt-manager-import').addEventListener('click', this.handleImport);

View File

@ -32,6 +32,7 @@ import {
getStringHash,
humanFileSize,
saveBase64AsFile,
extractTextFromOffice,
} from './utils.js';
import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
@ -46,6 +47,12 @@ import { ScraperManager } from './scrapers.js';
* @property {string} [text] File text
*/
/**
* @typedef {function} ConverterFunction
* @param {File} file File object
* @returns {Promise<string>} Converted file text
*/
const fileSizeLimit = 1024 * 1024 * 10; // 10 MB
const ATTACHMENT_SOURCE = {
GLOBAL: 'global',
@ -53,20 +60,60 @@ const ATTACHMENT_SOURCE = {
CHARACTER: 'character',
};
/**
* @type {Record<string, ConverterFunction>} File converters
*/
const converters = {
'application/pdf': extractTextFromPDF,
'text/html': extractTextFromHTML,
'text/markdown': extractTextFromMarkdown,
'application/epub+zip': extractTextFromEpub,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': extractTextFromOffice,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': extractTextFromOffice,
'application/vnd.openxmlformats-officedocument.presentationml.presentation': extractTextFromOffice,
'application/vnd.oasis.opendocument.text': extractTextFromOffice,
'application/vnd.oasis.opendocument.presentation': extractTextFromOffice,
'application/vnd.oasis.opendocument.spreadsheet': extractTextFromOffice,
};
/**
* Finds a matching key in the converters object.
* @param {string} type MIME type
* @returns {string} Matching key
*/
function findConverterKey(type) {
return Object.keys(converters).find((key) => {
// Match exact type
if (type === key) {
return true;
}
// Match wildcards
if (key.endsWith('*')) {
return type.startsWith(key.substring(0, key.length - 1));
}
return false;
});
}
/**
* Determines if the file type has a converter function.
* @param {string} type MIME type
* @returns {boolean} True if the file type is convertible, false otherwise.
*/
function isConvertible(type) {
return Object.keys(converters).includes(type);
return Boolean(findConverterKey(type));
}
/**
* Gets the converter function for a file type.
* @param {string} type MIME type
* @returns {ConverterFunction} Converter function
*/
function getConverter(type) {
const key = findConverterKey(type);
return key && converters[key];
}
/**
@ -152,7 +199,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
if (isConvertible(file.type)) {
try {
const converter = converters[file.type];
const converter = getConverter(file.type);
const fileText = await converter(file);
base64Data = window.btoa(unescape(encodeURIComponent(fileText)));
} catch (error) {
@ -584,18 +631,59 @@ async function openFilePopup(attachment) {
callGenericPopup(modalTemplate, POPUP_TYPE.TEXT, '', { wide: true, large: true });
}
/**
* Edit a file attachment in a notepad-like modal.
* @param {FileAttachment} attachment Attachment to edit
* @param {string} source Attachment source
* @param {function} callback Callback function
*/
async function editAttachment(attachment, source, callback) {
const originalFileText = attachment.text || (await getFileAttachment(attachment.url));
const template = $(await renderExtensionTemplateAsync('attachments', 'notepad'));
let editedFileText = originalFileText;
template.find('[name="notepadFileContent"]').val(editedFileText).on('input', function () {
editedFileText = String($(this).val());
});
let editedFileName = attachment.name;
template.find('[name="notepadFileName"]').val(editedFileName).on('input', function () {
editedFileName = String($(this).val());
});
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: true, large: true, okButton: 'Save', cancelButton: 'Cancel' });
if (result !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
if (editedFileText === originalFileText && editedFileName === attachment.name) {
return;
}
const nullCallback = () => { };
await deleteAttachment(attachment, source, nullCallback, false);
const file = new File([editedFileText], editedFileName, { type: 'text/plain' });
await uploadFileAttachmentToServer(file, source);
callback();
}
/**
* Deletes an attachment from the server and the chat.
* @param {FileAttachment} attachment Attachment to delete
* @param {string} source Source of the attachment
* @param {function} callback Callback function
* @param {boolean} [confirm=true] If true, show a confirmation dialog
* @returns {Promise<void>} A promise that resolves when the attachment is deleted.
*/
async function deleteAttachment(attachment, source, callback) {
const confirm = await callGenericPopup('Are you sure you want to delete this attachment?', POPUP_TYPE.CONFIRM);
async function deleteAttachment(attachment, source, callback, confirm = true) {
if (confirm) {
const result = await callGenericPopup('Are you sure you want to delete this attachment?', POPUP_TYPE.CONFIRM);
if (confirm !== POPUP_RESULT.AFFIRMATIVE) {
return;
if (result !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
}
ensureAttachmentsExist();
@ -672,6 +760,7 @@ async function openAttachmentManager() {
attachmentTemplate.find('.attachmentListItemSize').text(humanFileSize(attachment.size));
attachmentTemplate.find('.attachmentListItemCreated').text(new Date(attachment.created).toLocaleString());
attachmentTemplate.find('.viewAttachmentButton').on('click', () => openFilePopup(attachment));
attachmentTemplate.find('.editAttachmentButton').on('click', () => editAttachment(attachment, source, renderAttachments));
attachmentTemplate.find('.deleteAttachmentButton').on('click', () => deleteAttachment(attachment, source, renderAttachments));
template.find(sources[source]).append(attachmentTemplate);
}
@ -748,7 +837,7 @@ async function openAttachmentManager() {
}
async function renderAttachments() {
/** @type {FileAttachment[]} */
/** @type {FileAttachment[]} */
const globalAttachments = extension_settings.attachments ?? [];
/** @type {FileAttachment[]} */
const chatAttachments = chat_metadata.attachments ?? [];
@ -855,7 +944,7 @@ export async function uploadFileAttachmentToServer(file, target) {
if (isConvertible(file.type)) {
try {
const converter = converters[file.type];
const converter = getConverter(file.type);
const fileText = await converter(file);
base64Data = window.btoa(unescape(encodeURIComponent(fileText)));
} catch (error) {
@ -950,6 +1039,26 @@ export function getDataBankAttachmentsForSource(source) {
}
}
/**
* Registers a file converter function.
* @param {string} mimeType MIME type
* @param {ConverterFunction} converter Function to convert file
* @returns {void}
*/
export function registerFileConverter(mimeType, converter) {
if (typeof mimeType !== 'string' || typeof converter !== 'function') {
console.error('Invalid converter registration');
return;
}
if (Object.keys(converters).includes(mimeType)) {
console.error('Converter already registered');
return;
}
converters[mimeType] = converter;
}
jQuery(function () {
$(document).on('click', '.mes_hide', async function () {
const messageBlock = $(this).closest('.mes');

View File

@ -102,6 +102,7 @@
<small class="attachmentListItemCreated"></small>
<small class="attachmentListItemSize"></small>
<div class="viewAttachmentButton right_menu_button fa-solid fa-magnifying-glass" title="View attachment content"></div>
<div class="editAttachmentButton right_menu_button fa-solid fa-pencil" title="Edit attachment"></div>
<div class="deleteAttachmentButton right_menu_button fa-solid fa-trash" title="Delete attachment"></div>
</div>
</div>

View File

@ -0,0 +1,10 @@
<div class="flex-container flexFlowColumn height100p">
<label for="notepadFileName">
File Name
</label>
<input type="text" class="text_pole" id="notepadFileName" name="notepadFileName" value="" />
<labels>
File Content
</label>
<textarea id="notepadFileContent" name="notepadFileContent" class="text_pole textarea_compact monospace flex1" placeholder="Enter your notes here."></textarea>
</div>

View File

@ -53,6 +53,7 @@ const settings = {
// For files
enabled_files: false,
translate_files: false,
size_threshold: 10,
chunk_size: 5000,
chunk_count: 2,
@ -437,6 +438,12 @@ async function retrieveFileChunks(queryText, collectionId) {
*/
async function vectorizeFile(fileText, fileName, collectionId, chunkSize) {
try {
if (settings.translate_files && typeof window['translate'] === 'function') {
console.log(`Vectors: Translating file ${fileName} to English...`);
const translatedText = await window['translate'](fileText, 'en');
fileText = translatedText;
}
const toast = toastr.info('Vectorization may take some time, please wait...', `Ingesting file ${fileName}`);
const chunks = splitRecursive(fileText, chunkSize);
console.debug(`Vectors: Split file ${fileName} into ${chunks.length} chunks`, chunks);
@ -1121,6 +1128,12 @@ jQuery(async () => {
saveSettingsDebounced();
});
$('#vectors_translate_files').prop('checked', settings.translate_files).on('input', () => {
settings.translate_files = !!$('#vectors_translate_files').prop('checked');
Object.assign(extension_settings.vectors, settings);
saveSettingsDebounced();
});
const validSecret = !!secret_state[SECRET_KEYS.NOMICAI];
const placeholder = validSecret ? '✔️ Key saved' : '❌ Missing key';
$('#api_key_nomicai').attr('placeholder', placeholder);

View File

@ -107,6 +107,13 @@
</label>
<div id="vectors_files_settings" class="marginTopBot5">
<label class="checkbox_label" for="vectors_translate_files" title="This can help with retrieval accuracy if using embedding models that are trained on English data. Uses the selected API from Chat Translation extension settings.">
<input id="vectors_translate_files" type="checkbox" class="checkbox">
<span data-i18n="Translate files into English before processing">
Translate files into English before processing
</span>
<i class="fa-solid fa-flask" title="Experimental feature"></i>
</label>
<div class="flex justifyCenter" title="These settings apply to files attached directly to messages.">
<span>Message attachments</span>
</div>

View File

@ -1,4 +1,4 @@
import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId } from '../script.js';
import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId, substituteParams } from '../script.js';
import { timestampToMoment, isDigitsOnly, getStringHash } from './utils.js';
import { textgenerationwebui_banned_in_macros } from './textgen-settings.js';
import { replaceInstructMacros } from './instruct-mode.js';
@ -6,6 +6,12 @@ import { replaceVariableMacros } from './variables.js';
// Register any macro that you want to leave in the compiled story string
Handlebars.registerHelper('trim', () => '{{trim}}');
// Catch-all helper for any macro that is not defined for story strings
Handlebars.registerHelper('helperMissing', function () {
const options = arguments[arguments.length - 1];
const macroName = options.name;
return substituteParams(`{{${macroName}}}`);
});
/**
* Gets a hashed id of the current chat from the metadata.

View File

@ -77,6 +77,52 @@ export class ScraperManager {
}
}
/**
* Create a text file from a string.
* @implements {Scraper}
*/
class Notepad {
constructor() {
this.id = 'text';
this.name = 'Notepad';
this.description = 'Create a text file from scratch.';
this.iconClass = 'fa-solid fa-note-sticky';
}
/**
* Check if the scraper is available.
* @returns {Promise<boolean>}
*/
async isAvailable() {
return true;
}
/**
* Create a text file from a string.
* @returns {Promise<File[]>} File attachments scraped from the text
*/
async scrape() {
const template = $(await renderExtensionTemplateAsync('attachments', 'notepad', {}));
let fileName = `Untitled - ${new Date().toLocaleString()}`;
let text = '';
template.find('input[name="notepadFileName"]').val(fileName).on('input', function () {
fileName = String($(this).val()).trim();
});
template.find('textarea[name="notepadFileContent"]').on('input', function () {
text = String($(this).val());
});
const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', { wide: true, large: true, okButton: 'Save', cancelButton: 'Cancel' });
if (!result || text === '') {
return;
}
const file = new File([text], `Notepad - ${fileName}.txt`, { type: 'text/plain' });
return [file];
}
}
/**
* Scrape data from a webpage.
* @implements {Scraper}
@ -179,7 +225,7 @@ class FileScraper {
return new Promise(resolve => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.txt, .md, .pdf, .html, .htm, .epub';
fileInput.accept = '*/*';
fileInput.multiple = true;
fileInput.onchange = () => resolve(Array.from(fileInput.files));
fileInput.click();
@ -364,6 +410,7 @@ class YouTubeScraper {
}
ScraperManager.registerDataBankScraper(new FileScraper());
ScraperManager.registerDataBankScraper(new Notepad());
ScraperManager.registerDataBankScraper(new WebScraper());
ScraperManager.registerDataBankScraper(new FandomScraper());
ScraperManager.registerDataBankScraper(new YouTubeScraper());

View File

@ -1355,6 +1355,47 @@ export async function extractTextFromEpub(blob) {
return postProcessText(text.join('\n'), false);
}
/**
* Extracts text from an Office document using the server plugin.
* @param {File} blob File to extract text from
* @returns {Promise<string>} A promise that resolves to the extracted text.
*/
export async function extractTextFromOffice(blob) {
async function checkPluginAvailability() {
try {
const result = await fetch('/api/plugins/office/probe', {
method: 'POST',
headers: getRequestHeaders(),
});
return result.ok;
} catch (error) {
return false;
}
}
const isPluginAvailable = await checkPluginAvailability();
if (!isPluginAvailable) {
throw new Error('Importing Office documents requires a server plugin. Please refer to the documentation for more information.');
}
const base64 = await getBase64Async(blob);
const response = await fetch('/api/plugins/office/parse', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ data: base64 }),
});
if (!response.ok) {
throw new Error('Failed to parse the Office document');
}
const data = await response.text();
return postProcessText(data, false);
}
/**
* Sets a value in an object by a path.
* @param {object} obj Object to set value in