diff --git a/.github/readme.md b/.github/readme.md index f8430829f..11d99e105 100644 --- a/.github/readme.md +++ b/.github/readme.md @@ -229,7 +229,9 @@ You will need two mandatory directory mappings and a port mapping to allow Silly #### Install command 1. Open your Command Line -2. Run the following command `docker create --name='sillytavern' --net='[DockerNet]' -e TZ="[TimeZone]" -p '8000:8000/tcp' -v '[plugins]':'/home/node/app/plugins':'rw' -v '[config]':'/home/node/app/config':'rw' -v '[data]':'/home/node/app/data':'rw' 'ghcr.io/sillytavern/sillytavern:[version]' ` +2. Run the following command + +`docker create --name='sillytavern' --net='[DockerNet]' -e TZ="[TimeZone]" -p '8000:8000/tcp' -v '[plugins]':'/home/node/app/plugins':'rw' -v '[config]':'/home/node/app/config':'rw' -v '[data]':'/home/node/app/data':'rw' 'ghcr.io/sillytavern/sillytavern:[version]'` > Note that 8000 is a default listening port. Don't forget to use an appropriate port if you change it in the config. diff --git a/public/index.html b/public/index.html index d6744435a..8360e97d6 100644 --- a/public/index.html +++ b/public/index.html @@ -1224,6 +1224,11 @@ +
+ Rep Pen Decay + + +
Encoder Penalty @@ -1244,6 +1249,11 @@
+
+ Skew + + +
Min Length @@ -1272,6 +1282,45 @@
+ +
+

+ + +
+
+

+
+
+ Multiplier + + +
+
+ Base + + +
+
+ Allowed Length + + +
+
+ Penalty Range + + +
+
+
+
+ Sequence Breakers +
+
+ +
+
+

@@ -1405,6 +1454,13 @@
+
-
- - +
+
+ + +
+
+ + +
diff --git a/public/scripts/openai.js b/public/scripts/openai.js index c14df4566..f0cde3173 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -1743,8 +1743,8 @@ async function sendOpenAIRequest(type, messages, signal) { delete generate_data.stop; } - // Proxy is only supported for Claude, OpenAI and Mistral - if (oai_settings.reverse_proxy && [chat_completion_sources.CLAUDE, chat_completion_sources.OPENAI, chat_completion_sources.MISTRALAI].includes(oai_settings.chat_completion_source)) { + // Proxy is only supported for Claude, OpenAI, Mistral, and Google MakerSuite + if (oai_settings.reverse_proxy && [chat_completion_sources.CLAUDE, chat_completion_sources.OPENAI, chat_completion_sources.MISTRALAI, chat_completion_sources.MAKERSUITE].includes(oai_settings.chat_completion_source)) { validateReverseProxy(); generate_data['reverse_proxy'] = oai_settings.reverse_proxy; generate_data['proxy_password'] = oai_settings.proxy_password; @@ -3857,6 +3857,9 @@ async function onModelChange() { else if (['command-r', 'command-r-plus'].includes(oai_settings.cohere_model)) { $('#openai_max_context').attr('max', max_128k); } + else if(['c4ai-aya-23'].includes(oai_settings.cohere_model)) { + $('#openai_max_context').attr('max', max_8k); + } else { $('#openai_max_context').attr('max', max_4k); } @@ -4035,7 +4038,7 @@ async function onConnectButtonClick(e) { await writeSecret(SECRET_KEYS.MAKERSUITE, api_key_makersuite); } - if (!secret_state[SECRET_KEYS.MAKERSUITE]) { + if (!secret_state[SECRET_KEYS.MAKERSUITE] && !oai_settings.reverse_proxy) { console.log('No secret key saved for MakerSuite'); return; } @@ -4087,7 +4090,7 @@ async function onConnectButtonClick(e) { await writeSecret(SECRET_KEYS.MISTRALAI, api_key_mistralai); } - if (!secret_state[SECRET_KEYS.MISTRALAI]) { + if (!secret_state[SECRET_KEYS.MISTRALAI] && !oai_settings.reverse_proxy) { console.log('No secret key saved for MistralAI'); return; } diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js index 0e647269c..39aef63f2 100644 --- a/public/scripts/power-user.js +++ b/public/scripts/power-user.js @@ -678,6 +678,9 @@ async function CreateZenSliders(elmnt) { sliderID == 'top_k' || sliderID == 'mirostat_mode_kobold' || sliderID == 'rep_pen_range' || + sliderID == 'dry_allowed_length_textgenerationwebui' || + sliderID == 'rep_pen_decay_textgenerationwebui' || + sliderID == 'dry_penalty_last_n_textgenerationwebui' || sliderID == 'max_tokens_second_textgenerationwebui') { decimals = 0; } @@ -685,7 +688,9 @@ async function CreateZenSliders(elmnt) { sliderID == 'max_temp_textgenerationwebui' || sliderID == 'dynatemp_exponent_textgenerationwebui' || sliderID == 'smoothing_curve_textgenerationwebui' || - sliderID == 'smoothing_factor_textgenerationwebui') { + sliderID == 'smoothing_factor_textgenerationwebui' || + sliderID == 'dry_multiplier_textgenerationwebui' || + sliderID == 'dry_base_textgenerationwebui') { decimals = 2; } if (sliderID == 'eta_cutoff_textgenerationwebui' || @@ -746,6 +751,8 @@ async function CreateZenSliders(elmnt) { sliderID == 'rep_pen_slope' || sliderID == 'smoothing_factor_textgenerationwebui' || sliderID == 'smoothing_curve_textgenerationwebui' || + sliderID == 'skew_textgenerationwebui' || + sliderID == 'dry_multiplier_textgenerationwebui' || sliderID == 'min_length_textgenerationwebui') { offVal = 0; } @@ -1754,11 +1761,24 @@ function loadMaxContextUnlocked() { } function switchMaxContextSize() { - const elements = [$('#max_context'), $('#max_context_counter'), $('#rep_pen_range'), $('#rep_pen_range_counter'), $('#rep_pen_range_textgenerationwebui'), $('#rep_pen_range_counter_textgenerationwebui')]; + const elements = [ + $('#max_context'), + $('#max_context_counter'), + $('#rep_pen_range'), + $('#rep_pen_range_counter'), + $('#rep_pen_range_textgenerationwebui'), + $('#rep_pen_range_counter_textgenerationwebui'), + $('#dry_penalty_last_n_textgenerationwebui'), + $('#dry_penalty_last_n_counter_textgenerationwebui'), + $('#rep_pen_decay_textgenerationwebui'), + $('#rep_pen_decay_counter_textgenerationwebui'), + ]; const maxValue = power_user.max_context_unlocked ? MAX_CONTEXT_UNLOCKED : MAX_CONTEXT_DEFAULT; const minValue = power_user.max_context_unlocked ? maxContextMin : maxContextMin; const steps = power_user.max_context_unlocked ? unlockedMaxContextStep : maxContextStep; $('#rep_pen_range_textgenerationwebui_zenslider').remove(); //unsure why, but this is necessary. + $('#dry_penalty_last_n_textgenerationwebui_zenslider').remove(); + $('#rep_pen_decay_textgenerationwebui_zenslider').remove(); for (const element of elements) { const id = element.attr('id'); element.attr('max', maxValue); @@ -1787,6 +1807,10 @@ function switchMaxContextSize() { CreateZenSliders($('#max_context')); $('#rep_pen_range_textgenerationwebui_zenslider').remove(); CreateZenSliders($('#rep_pen_range_textgenerationwebui')); + $('#dry_penalty_last_n_textgenerationwebui_zenslider').remove(); + CreateZenSliders($('#dry_penalty_last_n_textgenerationwebui')); + $('#rep_pen_decay_textgenerationwebui_zenslider').remove(); + CreateZenSliders($('#rep_pen_decay_textgenerationwebui')); } } @@ -3009,7 +3033,7 @@ $(document).ready(() => { var coreTruthWinHeight = window.innerHeight; $(window).on('resize', async () => { - console.log(`Window resize: ${coreTruthWinWidth}x${coreTruthWinHeight} -> ${window.innerWidth}x${window.innerHeight}`) + console.log(`Window resize: ${coreTruthWinWidth}x${coreTruthWinHeight} -> ${window.innerWidth}x${window.innerHeight}`); adjustAutocompleteDebounced(); setHotswapsDebounced(); @@ -3062,7 +3086,7 @@ $(document).ready(() => { } } } else { - console.log('aborting MUI reset', Object.keys(power_user.movingUIState).length) + console.log('aborting MUI reset', Object.keys(power_user.movingUIState).length); } saveSettingsDebounced(); coreTruthWinWidth = window.innerWidth; diff --git a/public/scripts/secrets.js b/public/scripts/secrets.js index 4d7a6ebf1..cb4477a78 100644 --- a/public/scripts/secrets.js +++ b/public/scripts/secrets.js @@ -27,6 +27,7 @@ export const SECRET_KEYS = { COHERE: 'api_key_cohere', PERPLEXITY: 'api_key_perplexity', GROQ: 'api_key_groq', + AZURE_TTS: 'api_key_azure_tts', }; const INPUT_MAP = { diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index d273130fa..5baa11435 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -1112,6 +1112,9 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ new SlashCommandNamedArgument( 'role', 'role for in-chat injections', [ARGUMENT_TYPE.STRING], false, false, 'system', ['system', 'user', 'assistant'], ), + new SlashCommandNamedArgument( + 'ephemeral', 'remove injection after generation', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', ['true', 'false'], + ), ], unnamedArgumentList: [ new SlashCommandArgument( @@ -1173,6 +1176,7 @@ function injectCallback(args, value) { }; const id = resolveVariable(args?.id); + const ephemeral = isTrueBoolean(args?.ephemeral); if (!id) { console.warn('WARN: No ID provided for /inject command'); @@ -1206,6 +1210,23 @@ function injectCallback(args, value) { setExtensionPrompt(prefixedId, value, position, depth, scan, role); saveMetadataDebounced(); + + if (ephemeral) { + let deleted = false; + const unsetInject = () => { + if (deleted) { + return; + } + console.log('Removing ephemeral script injection', id); + delete chat_metadata.script_injects[id]; + setExtensionPrompt(prefixedId, '', position, depth, scan, role); + saveMetadataDebounced(); + deleted = true; + }; + eventSource.once(event_types.GENERATION_ENDED, unsetInject); + eventSource.once(event_types.GENERATION_STOPPED, unsetInject); + } + return ''; } diff --git a/public/scripts/textgen-settings.js b/public/scripts/textgen-settings.js index 8b7a438d6..9d2cd6214 100644 --- a/public/scripts/textgen-settings.js +++ b/public/scripts/textgen-settings.js @@ -100,6 +100,7 @@ const settings = { min_p: 0, rep_pen: 1.2, rep_pen_range: 0, + rep_pen_decay: 0, no_repeat_ngram_size: 0, penalty_alpha: 0, num_beams: 1, @@ -108,6 +109,7 @@ const settings = { encoder_rep_pen: 1, freq_pen: 0, presence_pen: 0, + skew: 0, do_sample: true, early_stopping: false, dynatemp: false, @@ -116,6 +118,11 @@ const settings = { dynatemp_exponent: 1.0, smoothing_factor: 0.0, smoothing_curve: 1.0, + dry_allowed_length: 2, + dry_multiplier: 0.0, + dry_base: 1.75, + dry_sequence_breakers: '["\\n", ":", "\\"", "*"]', + dry_penalty_last_n: 0, max_tokens_second: 0, seed: -1, preset: 'Default', @@ -139,6 +146,7 @@ const settings = { //best_of_aphrodite: 1, ignore_eos_token: false, spaces_between_special_tokens: true, + speculative_ngram: false, //logits_processors_aphrodite: [], //log_probs_aphrodite: 0, //prompt_log_probs_aphrodite: 0, @@ -171,6 +179,7 @@ export const setting_names = [ 'temperature_last', 'rep_pen', 'rep_pen_range', + 'rep_pen_decay', 'no_repeat_ngram_size', 'top_k', 'top_p', @@ -190,10 +199,16 @@ export const setting_names = [ 'dynatemp_exponent', 'smoothing_factor', 'smoothing_curve', + 'dry_allowed_length', + 'dry_multiplier', + 'dry_base', + 'dry_sequence_breakers', + 'dry_penalty_last_n', 'max_tokens_second', 'encoder_rep_pen', 'freq_pen', 'presence_pen', + 'skew', 'do_sample', 'early_stopping', 'seed', @@ -214,6 +229,7 @@ export const setting_names = [ //'best_of_aphrodite', 'ignore_eos_token', 'spaces_between_special_tokens', + 'speculative_ngram', //'logits_processors_aphrodite', //'log_probs_aphrodite', //'prompt_log_probs_aphrodite' @@ -638,6 +654,7 @@ jQuery(function () { 'min_p_textgenerationwebui': 0, 'rep_pen_textgenerationwebui': 1, 'rep_pen_range_textgenerationwebui': 0, + 'rep_pen_decay_textgenerationwebui': 0, 'dynatemp_textgenerationwebui': false, 'seed_textgenerationwebui': -1, 'ban_eos_token_textgenerationwebui': false, @@ -656,7 +673,9 @@ jQuery(function () { 'encoder_rep_pen_textgenerationwebui': 1, 'freq_pen_textgenerationwebui': 0, 'presence_pen_textgenerationwebui': 0, + 'skew_textgenerationwebui': 0, 'no_repeat_ngram_size_textgenerationwebui': 0, + 'speculative_ngram_textgenerationwebui': false, 'min_length_textgenerationwebui': 0, 'num_beams_textgenerationwebui': 1, 'length_penalty_textgenerationwebui': 1, @@ -665,6 +684,10 @@ jQuery(function () { 'guidance_scale_textgenerationwebui': 1, 'smoothing_factor_textgenerationwebui': 0, 'smoothing_curve_textgenerationwebui': 1, + 'dry_allowed_length_textgenerationwebui': 2, + 'dry_multiplier_textgenerationwebui': 0, + 'dry_base_textgenerationwebui': 1.75, + 'dry_penalty_last_n_textgenerationwebui': 0, }; for (const [id, value] of Object.entries(inputs)) { @@ -1014,6 +1037,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, 'frequency_penalty': settings.freq_pen, 'presence_penalty': settings.presence_pen, 'top_k': settings.top_k, + 'skew': settings.skew, 'min_length': settings.type === OOBA ? settings.min_length : undefined, 'minimum_message_content_tokens': settings.type === DREAMGEN ? settings.min_length : undefined, 'min_tokens': settings.min_length, @@ -1028,6 +1052,11 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, 'dynatemp_exponent': settings.dynatemp ? settings.dynatemp_exponent : undefined, 'smoothing_factor': settings.smoothing_factor, 'smoothing_curve': settings.smoothing_curve, + 'dry_allowed_length': settings.dry_allowed_length, + 'dry_multiplier': settings.dry_multiplier, + 'dry_base': settings.dry_base, + 'dry_sequence_breakers': settings.dry_sequence_breakers, + 'dry_penalty_last_n': settings.dry_penalty_last_n, 'max_tokens_second': settings.max_tokens_second, 'sampler_priority': settings.type === OOBA ? settings.sampler_priority : undefined, 'samplers': settings.type === LLAMACPP ? settings.samplers : undefined, @@ -1055,11 +1084,13 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, const nonAphroditeParams = { 'rep_pen': settings.rep_pen, 'rep_pen_range': settings.rep_pen_range, + 'repetition_decay': settings.type === TABBY ? settings.rep_pen_decay : undefined, 'repetition_penalty_range': settings.rep_pen_range, 'encoder_repetition_penalty': settings.type === OOBA ? settings.encoder_rep_pen : undefined, 'no_repeat_ngram_size': settings.type === OOBA ? settings.no_repeat_ngram_size : undefined, 'penalty_alpha': settings.type === OOBA ? settings.penalty_alpha : undefined, 'temperature_last': (settings.type === OOBA || settings.type === APHRODITE || settings.type == TABBY) ? settings.temperature_last : undefined, + 'speculative_ngram': settings.type === TABBY ? settings.speculative_ngram : undefined, 'do_sample': settings.type === OOBA ? settings.do_sample : undefined, 'seed': settings.seed, 'guidance_scale': cfgValues?.guidanceScale?.value ?? settings.guidance_scale ?? 1, diff --git a/public/scripts/tokenizers.js b/public/scripts/tokenizers.js index 3318cac98..39a19d0ca 100644 --- a/public/scripts/tokenizers.js +++ b/public/scripts/tokenizers.js @@ -560,7 +560,7 @@ export function countTokensOpenAI(messages, full = false) { if (shouldTokenizeAI21) { tokenizerEndpoint = '/api/tokenizers/ai21/count'; } else if (shouldTokenizeGoogle) { - tokenizerEndpoint = `/api/tokenizers/google/count?model=${getTokenizerModel()}`; + tokenizerEndpoint = `/api/tokenizers/google/count?model=${getTokenizerModel()}&reverse_proxy=${oai_settings.reverse_proxy}&proxy_password=${oai_settings.proxy_password}`; } else { tokenizerEndpoint = `/api/tokenizers/openai/count?model=${getTokenizerModel()}`; } diff --git a/public/scripts/utils.js b/public/scripts/utils.js index 63539513a..516a70412 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -1,5 +1,5 @@ import { getContext } from './extensions.js'; -import { getRequestHeaders } from '../script.js'; +import { callPopup, getRequestHeaders } from '../script.js'; import { isMobile } from './RossAscends-mods.js'; import { collapseNewlines } from './power-user.js'; import { debounce_timeout } from './constants.js'; @@ -139,6 +139,7 @@ export function download(content, fileName, contentType) { a.href = URL.createObjectURL(file); a.download = fileName; a.click(); + URL.revokeObjectURL(a.href); } /** @@ -1020,6 +1021,36 @@ export function extractDataFromPng(data, identifier = 'chara') { } } +/** + * Sends a request to the server to sanitize a given filename + * + * @param {string} fileName - The name of the file to sanitize + * @returns {Promise} A Promise that resolves to the sanitized filename if successful, or rejects with an error message if unsuccessful + */ +export async function getSanitizedFilename(fileName) { + try { + const result = await fetch('/api/files/sanitize-filename', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ + fileName: fileName, + }), + }); + + if (!result.ok) { + const error = await result.text(); + throw new Error(error); + } + + const responseData = await result.json(); + return responseData.fileName; + } catch (error) { + toastr.error(String(error), 'Could not sanitize fileName'); + console.error('Could not sanitize fileName', error); + throw error; + } +} + /** * Sends a base64 encoded image to the backend to be saved as a file. * @@ -1468,23 +1499,47 @@ export function flashHighlight(element, timespan = 2000) { setTimeout(() => element.removeClass('flash animated'), timespan); } +/** + * A common base function for case-insensitive and accent-insensitive string comparisons. + * + * @param {string} a - The first string to compare. + * @param {string} b - The second string to compare. + * @param {(a:string,b:string)=>boolean} comparisonFunction - The function to use for the comparison. + * @returns {*} - The result of the comparison. + */ +export function compareIgnoreCaseAndAccents(a, b, comparisonFunction) { + if (!a || !b) return comparisonFunction(a, b); // Return the comparison result if either string is empty + + // Normalize and remove diacritics, then convert to lower case + const normalizedA = a.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + const normalizedB = b.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + + // Check if the normalized strings are equal + return comparisonFunction(normalizedA, normalizedB); +} + /** * Performs a case-insensitive and accent-insensitive substring search. * This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents. * - * @param {string} text - The text in which to search for the substring. - * @param {string} searchTerm - The substring to search for in the text. - * @returns {boolean} - Returns true if the searchTerm is found within the text, otherwise returns false. + * @param {string} text - The text in which to search for the substring + * @param {string} searchTerm - The substring to search for in the text + * @returns {boolean} true if the searchTerm is found within the text, otherwise returns false */ export function includesIgnoreCaseAndAccents(text, searchTerm) { - if (!text || !searchTerm) return false; // Return false if either string is empty + return compareIgnoreCaseAndAccents(text, searchTerm, (a, b) => a?.includes(b) === true); +} - // Normalize and remove diacritics, then convert to lower case - const normalizedText = text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); - const normalizedSearchTerm = searchTerm.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); - - // Check if the normalized text includes the normalized search term - return normalizedText.includes(normalizedSearchTerm); +/** + * Performs a case-insensitive and accent-insensitive equality check. + * This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents. + * + * @param {string} a - The first string to compare + * @param {string} b - The second string to compare + * @returns {boolean} true if the strings are equal, otherwise returns false + */ +export function equalsIgnoreCaseAndAccents(a, b) { + return compareIgnoreCaseAndAccents(a, b, (a, b) => a === b); } /** @@ -1665,3 +1720,38 @@ export function highlightRegex(regexStr) { return `${regexStr}`; } + +/** + * Confirms if the user wants to overwrite an existing data object (like character, world info, etc) if one exists. + * If no data with the name exists, this simply returns true. + * + * @param {string} type - The type of the check ("World Info", "Character", etc) + * @param {string[]} existingNames - The list of existing names to check against + * @param {string} name - The new name + * @param {object} options - Optional parameters + * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when needing to overwrite an existing data object + * @param {string} [options.actionName='overwrite'] - The action name to display in the confirmation dialog + * @param {(existingName:string)=>void} [options.deleteAction=null] - Optional action to execute wen deleting an existing data object on overwrite + * @returns {Promise} True if the user confirmed the overwrite or there is no overwrite needed, false otherwise + */ +export async function checkOverwriteExistingData(type, existingNames, name, { interactive = false, actionName = 'Overwrite', deleteAction = null } = {}) { + const existing = existingNames.find(x => equalsIgnoreCaseAndAccents(x, name)); + if (!existing) { + return true; + } + + const overwrite = interactive ? await callPopup(`

${type} ${actionName}

A ${type.toLowerCase()} with the same name already exists:
${existing}

Do you want to overwrite it?`, 'confirm') : false; + if (!overwrite) { + toastr.warning(`${type} ${actionName.toLowerCase()} cancelled. A ${type.toLowerCase()} with the same name already exists:
${existing}`, `${type} ${actionName}`, { escapeHtml: false }); + return false; + } + + toastr.info(`Overwriting Existing ${type}:
${existing}`, `${type} ${actionName}`, { escapeHtml: false }); + + // If there is an action to delete the existing data, do it, as the name might be slightly different so file name would not be the same + if (deleteAction) { + deleteAction(existing); + } + + return true; +} diff --git a/public/scripts/variables.js b/public/scripts/variables.js index 5f3229692..c8e47c77f 100644 --- a/public/scripts/variables.js +++ b/public/scripts/variables.js @@ -795,27 +795,26 @@ function letCallback(args, value) { /** * Set or retrieve a variable in the current scope or nearest ancestor scope. - * @param {{_scope:SlashCommandScope, key?:string, index?:String|Number}} args Named arguments. - * @param {String|[String, SlashCommandClosure]} value Name and optional value for the variable. + * @param {{_scope:SlashCommandScope, key?:string, index?:string|number}} args Named arguments. + * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]} value Name and optional value for the variable. * @returns The variable's value */ function varCallback(args, value) { - if (Array.isArray(value)) { - args._scope.setVariable(value[0], typeof value[1] == 'string' ? value.slice(1).join(' ') : value[1], args.index); - return value[1]; - } + if (!Array.isArray(value)) value = [value]; if (args.key !== undefined) { const key = args.key; - const val = value; - args._scope.setVariable(key, val, args.index); - return val; - } else if (value.includes(' ')) { - const key = value.split(' ')[0]; - const val = value.split(' ').slice(1).join(' '); + const val = value.join(' '); args._scope.setVariable(key, val, args.index); return val; } - return args._scope.getVariable(args.key ?? value, args.index); + const key = value.shift(); + if (value.length > 0) { + const val = value.join(' '); + args._scope.setVariable(key, val, args.index); + return val; + } else { + return args._scope.getVariable(key, args.index); + } } export function registerVariableCommands() { @@ -1733,7 +1732,7 @@ export function registerVariableCommands() { returns: 'the variable value', namedArgumentList: [ new SlashCommandNamedArgument( - 'key', 'variable name', [ARGUMENT_TYPE.VARIABLE_NAME], false, + 'key', 'variable name; forces setting the variable, even if no value is provided', [ARGUMENT_TYPE.VARIABLE_NAME], false, ), new SlashCommandNamedArgument( 'index', @@ -1769,7 +1768,7 @@ export function registerVariableCommands() {
/let x foo | /var x foo bar | /var x | /echo
  • -
    /let x foo | /var key=x foo bar | /var key=x | /echo
    +
    /let x foo | /var key=x foo bar | /var x | /echo
  • diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 256821b3f..1fa9afd0f 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -1,5 +1,5 @@ import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js'; -import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean } from './utils.js'; +import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, equalsIgnoreCaseAndAccents, getSanitizedFilename, checkOverwriteExistingData } from './utils.js'; import { extension_settings, getContext } from './extensions.js'; import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js'; import { isMobile } from './RossAscends-mods.js'; @@ -50,6 +50,7 @@ const WI_ENTRY_EDIT_TEMPLATE = $('#entry_edit_template .world_entry'); let world_info = {}; let selected_world_info = []; +/** @type {string[]} */ let world_names; let world_info_depth = 2; let world_info_min_activations = 0; // if > 0, will continue seeking chat until minimum world infos are activated @@ -1057,6 +1058,34 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl return; } + // Regardless of whether success is displayed or not. Make sure the delete button is available. + // Do not put this code behind. + $('#world_popup_delete').off('click').on('click', async () => { + const confirmation = await callPopup(`

    Delete the World/Lorebook: "${name}"?

    This action is irreversible!`, 'confirm'); + + if (!confirmation) { + return; + } + + if (world_info.charLore) { + world_info.charLore.forEach((charLore, index) => { + if (charLore.extraBooks?.includes(name)) { + const tempCharLore = charLore.extraBooks.filter((e) => e !== name); + if (tempCharLore.length === 0) { + world_info.charLore.splice(index, 1); + } else { + charLore.extraBooks = tempCharLore; + } + } + }); + + saveSettingsDebounced(); + } + + // Selected world_info automatically refreshes + await deleteWorldInfo(name); + }); + // Before printing the WI, we check if we should enable/disable search sorting verifyWorldInfoSearchSortRule(); @@ -1225,32 +1254,6 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl } }); - $('#world_popup_delete').off('click').on('click', async () => { - const confirmation = await callPopup(`

    Delete the World/Lorebook: "${name}"?

    This action is irreversible!`, 'confirm'); - - if (!confirmation) { - return; - } - - if (world_info.charLore) { - world_info.charLore.forEach((charLore, index) => { - if (charLore.extraBooks?.includes(name)) { - const tempCharLore = charLore.extraBooks.filter((e) => e !== name); - if (tempCharLore.length === 0) { - world_info.charLore.splice(index, 1); - } else { - charLore.extraBooks = tempCharLore; - } - } - }); - - saveSettingsDebounced(); - } - - // Selected world_info automatically refreshes - await deleteWorldInfo(name); - }); - // Check if a sortable instance exists if (worldEntriesList.sortable('instance') !== undefined) { // Destroy the instance @@ -2542,9 +2545,15 @@ async function renameWorldInfo(name, data) { } } +/** + * Deletes a world info with the given name + * + * @param {string} worldInfoName - The name of the world info to delete + * @returns {Promise} A promise that resolves to true if the world info was successfully deleted, false otherwise + */ async function deleteWorldInfo(worldInfoName) { if (!world_names.includes(worldInfoName)) { - return; + return false; } const response = await fetch('/api/worldinfo/delete', { @@ -2553,24 +2562,28 @@ async function deleteWorldInfo(worldInfoName) { body: JSON.stringify({ name: worldInfoName }), }); - if (response.ok) { - const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName); - if (existingWorldIndex !== -1) { - selected_world_info.splice(existingWorldIndex, 1); - saveSettingsDebounced(); - } + if (!response.ok) { + return false; + } - await updateWorldInfoList(); - $('#world_editor_select').trigger('change'); + const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName); + if (existingWorldIndex !== -1) { + selected_world_info.splice(existingWorldIndex, 1); + saveSettingsDebounced(); + } - if ($('#character_world').val() === worldInfoName) { - $('#character_world').val('').trigger('change'); - setWorldInfoButtonClass(undefined, false); - if (menu_type != 'create') { - saveCharacterDebounced(); - } + await updateWorldInfoList(); + $('#world_editor_select').trigger('change'); + + if ($('#character_world').val() === worldInfoName) { + $('#character_world').val('').trigger('change'); + setWorldInfoButtonClass(undefined, false); + if (menu_type != 'create') { + saveCharacterDebounced(); } } + + return true; } function getFreeWorldEntryUid(data) { @@ -2602,22 +2615,40 @@ function getFreeWorldName() { return undefined; } -async function createNewWorldInfo(worldInfoName) { +/** + * Creates a new world info/lorebook with the given name. + * Checks if a world with the same name already exists, providing a warning or optionally a user confirmation dialog. + * + * @param {string} worldName - The name of the new world info + * @param {Object} options - Optional parameters + * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when overwriting an existing world + * @returns {Promise} - True if the world info was successfully created, false otherwise + */ +async function createNewWorldInfo(worldName, { interactive = false } = {}) { const worldInfoTemplate = { entries: {} }; - if (!worldInfoName) { - return; + if (!worldName) { + return false; } - await saveWorldInfo(worldInfoName, worldInfoTemplate, true); + const sanitizedWorldName = await getSanitizedFilename(worldName); + + const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: interactive, actionName: 'Create', deleteAction: (existingName) => deleteWorldInfo(existingName) }); + if (!allowed) { + return false; + } + + await saveWorldInfo(worldName, worldInfoTemplate, true); await updateWorldInfoList(); - const selectedIndex = world_names.indexOf(worldInfoName); + const selectedIndex = world_names.indexOf(worldName); if (selectedIndex !== -1) { $('#world_editor_select').val(selectedIndex).trigger('change'); } else { hideWorldEditor(); } + + return true; } async function getCharacterLore() { @@ -3550,6 +3581,13 @@ export async function importWorldInfo(file) { return; } + const worldName = file.name.substr(0, file.name.lastIndexOf(".")); + const sanitizedWorldName = await getSanitizedFilename(worldName); + const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) }); + if (!allowed) { + return false; + } + jQuery.ajax({ type: 'POST', url: '/api/worldinfo/import', @@ -3567,7 +3605,7 @@ export async function importWorldInfo(file) { $('#world_editor_select').val(newIndex).trigger('change'); } - toastr.info(`World Info "${data.name}" imported successfully!`); + toastr.success(`World Info "${data.name}" imported successfully!`); } }, error: (_jqXHR, _exception) => { }, @@ -3642,7 +3680,7 @@ jQuery(() => { const finalName = await callPopup('

    Create a new World Info?

    Enter a name for the new file:', 'input', tempName); if (finalName) { - await createNewWorldInfo(finalName); + await createNewWorldInfo(finalName, { interactive: true }); } }); diff --git a/server.js b/server.js index e658d7b5e..6f67dc287 100644 --- a/server.js +++ b/server.js @@ -519,6 +519,9 @@ app.use('/api/backends/scale-alt', require('./src/endpoints/backends/scale-alt') // Speech (text-to-speech and speech-to-text) app.use('/api/speech', require('./src/endpoints/speech').router); +// Azure TTS +app.use('/api/azure', require('./src/endpoints/azure').router); + const tavernUrl = new URL( (cliArguments.ssl ? 'https://' : 'http://') + (listen ? '0.0.0.0' : '127.0.0.1') + diff --git a/src/endpoints/azure.js b/src/endpoints/azure.js new file mode 100644 index 000000000..4c3b34d5b --- /dev/null +++ b/src/endpoints/azure.js @@ -0,0 +1,92 @@ +const { readSecret, SECRET_KEYS } = require('./secrets'); +const fetch = require('node-fetch').default; +const express = require('express'); +const { jsonParser } = require('../express-common'); + +const router = express.Router(); + +router.post('/list', jsonParser, async (req, res) => { + try { + const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS); + + if (!key) { + console.error('Azure TTS API Key not set'); + return res.sendStatus(403); + } + + const region = req.body.region; + + if (!region) { + console.error('Azure TTS region not set'); + return res.sendStatus(400); + } + + const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/voices/list`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Ocp-Apim-Subscription-Key': key, + }, + }); + + if (!response.ok) { + console.error('Azure Request failed', response.status, response.statusText); + return res.sendStatus(500); + } + + const voices = await response.json(); + return res.json(voices); + } catch (error) { + console.error('Azure Request failed', error); + return res.sendStatus(500); + } +}); + +router.post('/generate', jsonParser, async (req, res) => { + try { + const key = readSecret(req.user.directories, SECRET_KEYS.AZURE_TTS); + + if (!key) { + console.error('Azure TTS API Key not set'); + return res.sendStatus(403); + } + + const { text, voice, region } = req.body; + if (!text || !voice || !region) { + console.error('Missing required parameters'); + return res.sendStatus(400); + } + + const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`; + const lang = String(voice).split('-').slice(0, 2).join('-'); + const escapedText = String(text).replace(/&/g, '&').replace(//g, '>'); + const ssml = `${escapedText}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Ocp-Apim-Subscription-Key': key, + 'Content-Type': 'application/ssml+xml', + 'X-Microsoft-OutputFormat': 'ogg-48khz-16bit-mono-opus', + }, + body: ssml, + }); + + if (!response.ok) { + console.error('Azure Request failed', response.status, response.statusText); + return res.sendStatus(500); + } + + const audio = await response.buffer(); + res.set('Content-Type', 'audio/ogg'); + return res.send(audio); + } catch (error) { + console.error('Azure Request failed', error); + return res.sendStatus(500); + } +}); + +module.exports = { + router, +}; diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js index becfc8e07..f4cbe85c2 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -16,6 +16,7 @@ const API_MISTRAL = 'https://api.mistral.ai/v1'; const API_COHERE = 'https://api.cohere.ai/v1'; const API_PERPLEXITY = 'https://api.perplexity.ai'; const API_GROQ = 'https://api.groq.com/openai/v1'; +const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; /** * Applies a post-processing step to the generated messages. @@ -232,9 +233,10 @@ async function sendScaleRequest(request, response) { * @param {express.Response} response Express response */ async function sendMakerSuiteRequest(request, response) { - const apiKey = readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); + const apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE); + const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); - if (!apiKey) { + if (!request.body.reverse_proxy && !apiKey) { console.log('MakerSuite API key is missing.'); return response.status(400).send({ error: true }); } @@ -316,7 +318,7 @@ async function sendMakerSuiteRequest(request, response) { ? (stream ? 'streamGenerateContent' : 'generateContent') : (isText ? 'generateText' : 'generateMessage'); - const generateResponse = await fetch(`https://generativelanguage.googleapis.com/${apiVersion}/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`, { + const generateResponse = await fetch(`${apiUrl.origin}/${apiVersion}/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`, { body: JSON.stringify(body), method: 'POST', headers: { diff --git a/src/endpoints/files.js b/src/endpoints/files.js index 371381c21..000110758 100644 --- a/src/endpoints/files.js +++ b/src/endpoints/files.js @@ -2,11 +2,27 @@ const path = require('path'); const fs = require('fs'); const writeFileSyncAtomic = require('write-file-atomic').sync; const express = require('express'); +const sanitize = require('sanitize-filename'); const router = express.Router(); const { validateAssetFileName } = require('./assets'); const { jsonParser } = require('../express-common'); const { clientRelativePath } = require('../util'); +router.post('/sanitize-filename', jsonParser, async (request, response) => { + try { + const fileName = String(request.body.fileName); + if (!fileName) { + return response.status(400).send('No fileName specified'); + } + + const sanitizedFilename = sanitize(fileName); + return response.send({ fileName: sanitizedFilename }); + } catch (error) { + console.log(error); + return response.sendStatus(500); + } +}); + router.post('/upload', jsonParser, async (request, response) => { try { if (!request.body.name) { diff --git a/src/endpoints/google.js b/src/endpoints/google.js index 65c67d0cd..b32c4baf6 100644 --- a/src/endpoints/google.js +++ b/src/endpoints/google.js @@ -4,14 +4,18 @@ const express = require('express'); const { jsonParser } = require('../express-common'); const { GEMINI_SAFETY } = require('../constants'); +const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; + const router = express.Router(); router.post('/caption-image', jsonParser, async (request, response) => { try { const mimeType = request.body.image.split(';')[0].split(':')[1]; const base64Data = request.body.image.split(',')[1]; - const key = readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); - const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${key}`; + const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); + const apiUrl = new URL(request.body.reverse_proxy || API_MAKERSUITE); + const model = request.body.model || 'gemini-pro-vision'; + const url = `${apiUrl.origin}/v1beta/models/${model}:generateContent?key=${apiKey}`; const body = { contents: [{ parts: [ @@ -27,7 +31,7 @@ router.post('/caption-image', jsonParser, async (request, response) => { generationConfig: { maxOutputTokens: 1000 }, }; - console.log('Multimodal captioning request', body); + console.log('Multimodal captioning request', model, body); const result = await fetch(url, { body: JSON.stringify(body), diff --git a/src/endpoints/secrets.js b/src/endpoints/secrets.js index 1a1ac3746..9bf2eb765 100644 --- a/src/endpoints/secrets.js +++ b/src/endpoints/secrets.js @@ -39,6 +39,7 @@ const SECRET_KEYS = { COHERE: 'api_key_cohere', PERPLEXITY: 'api_key_perplexity', GROQ: 'api_key_groq', + AZURE_TTS: 'api_key_azure_tts', }; // These are the keys that are safe to expose, even if allowKeysExposure is false diff --git a/src/endpoints/tokenizers.js b/src/endpoints/tokenizers.js index 82bab1439..7ed3c603a 100644 --- a/src/endpoints/tokenizers.js +++ b/src/endpoints/tokenizers.js @@ -10,6 +10,8 @@ const { TEXTGEN_TYPES } = require('../constants'); const { jsonParser } = require('../express-common'); const { setAdditionalHeaders } = require('../additional-headers'); +const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; + /** * @typedef { (req: import('express').Request, res: import('express').Response) => Promise } TokenizationHandler */ @@ -555,8 +557,11 @@ router.post('/google/count', jsonParser, async function (req, res) { body: JSON.stringify({ contents: convertGooglePrompt(req.body, String(req.query.model)).contents }), }; try { - const key = readSecret(req.user.directories, SECRET_KEYS.MAKERSUITE); - const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${req.query.model}:countTokens?key=${key}`, options); + const reverseProxy = req.query.reverse_proxy?.toString() || ''; + const proxyPassword = req.query.proxy_password?.toString() || ''; + const apiKey = reverseProxy ? proxyPassword : readSecret(req.user.directories, SECRET_KEYS.MAKERSUITE); + const apiUrl = new URL(reverseProxy || API_MAKERSUITE); + const response = await fetch(`${apiUrl.origin}/v1beta/models/${req.query.model}:countTokens?key=${apiKey}`, options); const data = await response.json(); return res.send({ 'token_count': data?.totalTokens || 0 }); } catch (err) { diff --git a/src/endpoints/vectors.js b/src/endpoints/vectors.js index 990796fb1..519b4c284 100644 --- a/src/endpoints/vectors.js +++ b/src/endpoints/vectors.js @@ -168,14 +168,15 @@ async function deleteVectorItems(directories, collectionId, source, hashes) { * @param {Object} sourceSettings - Settings for the source, if it needs any * @param {string} searchText - The text to search for * @param {number} topK - The number of results to return + * @param {number} threshold - The threshold for the search * @returns {Promise<{hashes: number[], metadata: object[]}>} - The metadata of the items that match the search text */ -async function queryCollection(directories, collectionId, source, sourceSettings, searchText, topK) { +async function queryCollection(directories, collectionId, source, sourceSettings, searchText, topK, threshold) { const store = await getIndex(directories, collectionId, source); const vector = await getVector(source, sourceSettings, searchText, true, directories); const result = await store.queryItems(vector, topK); - const metadata = result.map(x => x.item.metadata); + const metadata = result.filter(x => x.score >= threshold).map(x => x.item.metadata); const hashes = result.map(x => Number(x.item.metadata.hash)); return { metadata, hashes }; } @@ -188,9 +189,11 @@ async function queryCollection(directories, collectionId, source, sourceSettings * @param {Object} sourceSettings - Settings for the source, if it needs any * @param {string} searchText - The text to search for * @param {number} topK - The number of results to return + * @param {number} threshold - The threshold for the search + * * @returns {Promise>} - The top K results from each collection */ -async function multiQueryCollection(directories, collectionIds, source, sourceSettings, searchText, topK) { +async function multiQueryCollection(directories, collectionIds, source, sourceSettings, searchText, topK, threshold) { const vector = await getVector(source, sourceSettings, searchText, true, directories); const results = []; @@ -200,9 +203,10 @@ async function multiQueryCollection(directories, collectionIds, source, sourceSe results.push(...result.map(result => ({ collectionId, result }))); } - // Sort results by descending similarity + // Sort results by descending similarity, apply threshold, and take top K const sortedResults = results .sort((a, b) => b.result.score - a.result.score) + .filter(x => x.result.score >= threshold) .slice(0, topK); /** @@ -274,10 +278,11 @@ router.post('/query', jsonParser, async (req, res) => { const collectionId = String(req.body.collectionId); const searchText = String(req.body.searchText); const topK = Number(req.body.topK) || 10; + const threshold = Number(req.body.threshold) || 0.0; const source = String(req.body.source) || 'transformers'; const sourceSettings = getSourceSettings(source, req); - const results = await queryCollection(req.user.directories, collectionId, source, sourceSettings, searchText, topK); + const results = await queryCollection(req.user.directories, collectionId, source, sourceSettings, searchText, topK, threshold); return res.json(results); } catch (error) { console.error(error); @@ -294,10 +299,11 @@ router.post('/query-multi', jsonParser, async (req, res) => { const collectionIds = req.body.collectionIds.map(x => String(x)); const searchText = String(req.body.searchText); const topK = Number(req.body.topK) || 10; + const threshold = Number(req.body.threshold) || 0.0; const source = String(req.body.source) || 'transformers'; const sourceSettings = getSourceSettings(source, req); - const results = await multiQueryCollection(req.user.directories, collectionIds, source, sourceSettings, searchText, topK); + const results = await multiQueryCollection(req.user.directories, collectionIds, source, sourceSettings, searchText, topK, threshold); return res.json(results); } catch (error) { console.error(error);