Merge branch 'staging' into vllm-tc

This commit is contained in:
Cohee
2024-07-06 11:18:38 +03:00
105 changed files with 4255 additions and 1725 deletions

View File

@@ -360,6 +360,7 @@ function RA_autoconnect(PrevApi) {
|| (textgen_settings.type === textgen_types.INFERMATICAI && secret_state[SECRET_KEYS.INFERMATICAI])
|| (textgen_settings.type === textgen_types.DREAMGEN && secret_state[SECRET_KEYS.DREAMGEN])
|| (textgen_settings.type === textgen_types.OPENROUTER && secret_state[SECRET_KEYS.OPENROUTER])
|| (textgen_settings.type === textgen_types.FEATHERLESS && secret_state[SECRET_KEYS.FEATHERLESS])
) {
$('#api_button_textgenerationwebui').trigger('click');
}
@@ -379,6 +380,7 @@ function RA_autoconnect(PrevApi) {
|| (secret_state[SECRET_KEYS.COHERE] && oai_settings.chat_completion_source == chat_completion_sources.COHERE)
|| (secret_state[SECRET_KEYS.PERPLEXITY] && oai_settings.chat_completion_source == chat_completion_sources.PERPLEXITY)
|| (secret_state[SECRET_KEYS.GROQ] && oai_settings.chat_completion_source == chat_completion_sources.GROQ)
|| (secret_state[SECRET_KEYS.ZEROONEAI] && oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI)
|| (isValidUrl(oai_settings.custom_url) && oai_settings.chat_completion_source == chat_completion_sources.CUSTOM)
) {
$('#api_button_openai').trigger('click');
@@ -476,8 +478,8 @@ export function dragElement(elmnt) {
}
const style = getComputedStyle(target);
height = parseInt(style.height)
width = parseInt(style.width)
height = parseInt(style.height);
width = parseInt(style.width);
top = parseInt(style.top);
left = parseInt(style.left);
right = parseInt(style.right);
@@ -723,6 +725,16 @@ export function initRossMods() {
RA_autoconnect();
}
const userAgent = getParsedUA();
console.debug('User Agent', userAgent);
const isMobileSafari = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isDesktopSafari = userAgent?.browser?.name === 'Safari' && userAgent?.platform?.type === 'desktop';
const isIOS = userAgent?.os?.name === 'iOS';
if (isIOS || isMobileSafari || isDesktopSafari) {
document.body.classList.add('safari');
}
$('#main_api').change(function () {
var PrevAPI = main_api;
setTimeout(() => RA_autoconnect(PrevAPI), 100);
@@ -926,8 +938,8 @@ export function initRossMods() {
return false;
}
$(document).on('keydown', function (event) {
processHotkeys(event.originalEvent);
$(document).on('keydown', async function (event) {
await processHotkeys(event.originalEvent);
});
const hotkeyTargets = {
@@ -939,7 +951,7 @@ export function initRossMods() {
/**
* @param {KeyboardEvent} event
*/
function processHotkeys(event) {
async function processHotkeys(event) {
//Enter to send when send_textarea in focus
if (document.activeElement == hotkeyTargets['send_textarea']) {
const sendOnEnter = shouldSendOnEnter();
@@ -1003,21 +1015,17 @@ export function initRossMods() {
if (skipConfirm) {
doRegenerate();
} else {
Popup.show.confirm('Regenerate Message', `
<span>Are you sure you want to regenerate the latest message?</span>
<label class="checkbox_label justifyCenter marginTop10" for="regenerateWithCtrlEnter">
<input type="checkbox" id="regenerateWithCtrlEnter">
Don't ask again
</label>`, {
onClose: (popup) => {
if (!popup.result) {
return;
}
const regenerateWithCtrlEnter = $('#regenerateWithCtrlEnter').prop('checked');
SaveLocal(skipConfirmKey, regenerateWithCtrlEnter);
doRegenerate();
},
})
let regenerateWithCtrlEnter = false;
const result = await Popup.show.confirm('Regenerate Message', 'Are you sure you want to regenerate the latest message?', {
customInputs: [{ id: 'regenerateWithCtrlEnter', label: 'Don\'t ask again' }],
onClose: (popup) => regenerateWithCtrlEnter = popup.inputResults.get('regenerateWithCtrlEnter') ?? false,
});
if (!result) {
return;
}
SaveLocal(skipConfirmKey, regenerateWithCtrlEnter);
doRegenerate();
}
return;
} else {

View File

@@ -24,6 +24,7 @@ import {
saveGroupBookmarkChat,
selected_group,
} from './group-chats.js';
import { Popup } from './popup.js';
import { createTagMapFromList } from './tags.js';
import {
@@ -239,8 +240,7 @@ async function convertSoloToGroupChat() {
return;
}
const confirm = await callPopup('Are you sure you want to convert this chat to a group chat?', 'confirm');
const confirm = await Popup.show.confirm('Convert to group chat', 'Are you sure you want to convert this chat to a group chat?<br />This cannot be reverted.');
if (!confirm) {
return;
}
@@ -336,6 +336,7 @@ async function convertSoloToGroupChat() {
if (!createChatResponse.ok) {
console.error('Group chat creation unsuccessful');
toastr.error('Group chat creation unsuccessful');
return;
}

View File

@@ -185,18 +185,19 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
const file = fileInput.files[0];
if (!file) return;
const slug = getStringHash(file.name);
const fileNamePrefix = `${Date.now()}_${slug}`;
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);
const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension);
message.extra.image = imageUrl;
message.extra.inline_image = true;
} else {
const slug = getStringHash(file.name);
const uniqueFileName = `${Date.now()}_${slug}.txt`;
const uniqueFileName = `${fileNamePrefix}.txt`;
if (isConvertible(file.type)) {
try {
@@ -319,12 +320,10 @@ export function hasPendingFileAttachment() {
/**
* Displays file information in the message sending form.
* @param {File} file File object
* @returns {Promise<void>}
*/
async function onFileAttach() {
const fileInput = document.getElementById('file_form_input');
if (!(fileInput instanceof HTMLInputElement)) return;
const file = fileInput.files[0];
async function onFileAttach(file) {
if (!file) return;
const isValid = await validateFile(file);
@@ -419,6 +418,7 @@ function embedMessageFile(messageId, messageBlock) {
}
await populateFileAttachment(message, 'embed_file_input');
await eventSource.emit(event_types.MESSAGE_FILE_EMBEDDED, messageId);
appendMediaToMessage(message, messageBlock);
await saveChatConditional();
}
@@ -616,6 +616,8 @@ async function deleteMessageImage() {
const message = chat[mesId];
delete message.extra.image;
delete message.extra.inline_image;
delete message.extra.title;
delete message.extra.append_title;
mesBlock.find('.mes_img_container').removeClass('img_extra');
mesBlock.find('.mes_img').attr('src', '');
await saveChatConditional();
@@ -1119,7 +1121,7 @@ async function openAttachmentManager() {
const cleanupFn = await renderButtons();
await verifyAttachments();
await renderAttachments();
await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, okButton: 'Close' });
await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, okButton: 'Close', allowVerticalScrolling: true });
cleanupFn();
dragDropHandler.destroy();
@@ -1428,7 +1430,7 @@ jQuery(function () {
wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter');
const textarea = document.createElement('textarea');
textarea.value = String(bro.val());
textarea.classList.add('height100p', 'wide100p');
textarea.classList.add('height100p', 'wide100p', 'maximized_textarea');
bro.hasClass('monospace') && textarea.classList.add('monospace');
textarea.addEventListener('input', function () {
bro.val(textarea.value).trigger('input');
@@ -1503,8 +1505,34 @@ jQuery(function () {
$(document).on('click', '.mes_img_enlarge', enlargeMessageImage);
$(document).on('click', '.mes_img_delete', deleteMessageImage);
$('#file_form_input').on('change', onFileAttach);
$('#file_form_input').on('change', async () => {
const fileInput = document.getElementById('file_form_input');
if (!(fileInput instanceof HTMLInputElement)) return;
const file = fileInput.files[0];
await onFileAttach(file);
});
$('#file_form').on('reset', function () {
$('#file_form').addClass('displayNone');
});
document.getElementById('send_textarea').addEventListener('paste', async function (event) {
if (event.clipboardData.files.length === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
const fileInput = document.getElementById('file_form_input');
if (!(fileInput instanceof HTMLInputElement)) return;
// Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files
const dataTransfer = new DataTransfer();
for (let i = 0; i < event.clipboardData.files.length; i++) {
dataTransfer.items.add(event.clipboardData.files[i]);
}
fileInput.files = dataTransfer.files;
await onFileAttach(fileInput.files[0]);
});
});

View File

@@ -5,6 +5,8 @@
export const debounce_timeout = {
/** [100 ms] For ultra-fast responses, typically for keypresses or executions that might happen multiple times in a loop or recursion. */
quick: 100,
/** [200 ms] Slightly slower than quick, but still very responsive. */
short: 200,
/** [300 ms] Default time for general use, good balance between responsiveness and performance. */
standard: 300,
/** [1.000 ms] For situations where the function triggers more intensive tasks. */

View File

@@ -154,7 +154,7 @@ export function initDynamicStyles() {
// Process all stylesheets on initial load
Array.from(document.styleSheets).forEach(sheet => {
try {
applyDynamicFocusStyles(sheet, { fromExtension: sheet.href.toLowerCase().includes('scripts/extensions') });
applyDynamicFocusStyles(sheet, { fromExtension: sheet.href?.toLowerCase().includes('scripts/extensions') == true });
} catch (e) {
console.warn('Failed to process stylesheet on initial load:', e);
}

View File

@@ -374,7 +374,7 @@ async function addExtensionsButtonAndMenu() {
$('html').on('click', function (e) {
const clickTarget = $(e.target);
const noCloseTargets = ['#sd_gen', '#extensionsMenuButton'];
const noCloseTargets = ['#sd_gen', '#extensionsMenuButton', '#roll_dice'];
if (dropdown.is(':visible') && !noCloseTargets.some(id => clickTarget.closest(id).length > 0)) {
$(dropdown).fadeOut(animation_duration);
}
@@ -641,9 +641,16 @@ async function showExtensionsDetails() {
action: async () => {
requiresReload = true;
await autoUpdateExtensions(true);
popup.complete(POPUP_RESULT.AFFIRMATIVE);
await popup.complete(POPUP_RESULT.AFFIRMATIVE);
},
};
// If we are updating an extension, the "old" popup is still active. We should close that.
const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info'));
if (oldPopup) {
await oldPopup.complete(POPUP_RESULT.CANCELLED);
}
const popup = new Popup(`<div class="extensions_info">${html}</div>`, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, customButtons: [updateAllButton], allowVerticalScrolling: true });
popupPromise = popup.show();
} catch (error) {

View File

@@ -1,6 +1,6 @@
import { ensureImageFormatSupported, getBase64Async, isTrueBoolean, saveBase64AsFile } from '../../utils.js';
import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';
import { callPopup, getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
import { appendMediaToMessage, callPopup, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
import { getMessageTimeStamp } from '../../RossAscends-mods.js';
import { SECRET_KEYS, secret_state } from '../../secrets.js';
import { getMultimodalCaption } from '../shared.js';
@@ -84,12 +84,11 @@ async function setSpinnerIcon() {
}
/**
* Sends a captioned message to the chat.
* @param {string} caption Caption text
* @param {string} image Image URL
* Wraps a caption with a message template.
* @param {string} caption Raw caption
* @returns {Promise<string>} Wrapped caption
*/
async function sendCaptionedMessage(caption, image) {
const context = getContext();
async function wrapCaptionTemplate(caption) {
let template = extension_settings.caption.template || TEMPLATE_DEFAULT;
if (!/{{caption}}/i.test(template)) {
@@ -101,7 +100,7 @@ async function sendCaptionedMessage(caption, image) {
if (extension_settings.caption.refine_mode) {
messageText = await callPopup(
'<h3>Review and edit the generated message:</h3>Press "Cancel" to abort the caption sending.',
'<h3>Review and edit the generated caption:</h3>Press "Cancel" to abort the caption sending.',
'input',
messageText,
{ rows: 5, okButton: 'Send' });
@@ -111,6 +110,55 @@ async function sendCaptionedMessage(caption, image) {
}
}
return messageText;
}
/**
* Appends caption to an existing message.
* @param {Object} data Message data
* @returns {Promise<void>}
*/
async function captionExistingMessage(data) {
if (!(data?.extra?.image)) {
return;
}
const imageData = await fetch(data.extra.image);
const blob = await imageData.blob();
const type = imageData.headers.get('Content-Type');
const file = new File([blob], 'image.png', { type });
const caption = await getCaptionForFile(file, null, true);
if (!caption) {
console.warn('Failed to generate a caption for the image.');
return;
}
const wrappedCaption = await wrapCaptionTemplate(caption);
const messageText = String(data.mes).trim();
if (!messageText) {
data.extra.inline_image = false;
data.mes = wrappedCaption;
data.extra.title = wrappedCaption;
}
else {
data.extra.inline_image = true;
data.extra.append_title = true;
data.extra.title = wrappedCaption;
}
}
/**
* Sends a captioned message to the chat.
* @param {string} caption Caption text
* @param {string} image Image URL
*/
async function sendCaptionedMessage(caption, image) {
const messageText = await wrapCaptionTemplate(caption);
const context = getContext();
const message = {
name: context.name1,
is_user: true,
@@ -356,6 +404,7 @@ jQuery(async function () {
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'llamacpp' && textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP]) ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'ooba' && textgenerationwebui_settings.server_urls[textgen_types.OOBA]) ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'koboldcpp' && textgenerationwebui_settings.server_urls[textgen_types.KOBOLDCPP]) ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'vllm' && textgenerationwebui_settings.server_urls[textgen_types.VLLM]) ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'custom') ||
extension_settings.caption.source === 'local' ||
extension_settings.caption.source === 'horde';
@@ -408,7 +457,7 @@ jQuery(async function () {
});
}
async function addSettings() {
const html = await renderExtensionTemplateAsync('caption', 'settings');
const html = await renderExtensionTemplateAsync('caption', 'settings', { TEMPLATE_DEFAULT, PROMPT_DEFAULT });
$('#caption_container').append(html);
}
@@ -422,6 +471,7 @@ jQuery(async function () {
$('#caption_refine_mode').prop('checked', !!(extension_settings.caption.refine_mode));
$('#caption_allow_reverse_proxy').prop('checked', !!(extension_settings.caption.allow_reverse_proxy));
$('#caption_prompt_ask').prop('checked', !!(extension_settings.caption.prompt_ask));
$('#caption_auto_mode').prop('checked', !!(extension_settings.caption.auto_mode));
$('#caption_source').val(extension_settings.caption.source);
$('#caption_prompt').val(extension_settings.caption.prompt);
$('#caption_template').val(extension_settings.caption.template);
@@ -447,6 +497,41 @@ jQuery(async function () {
extension_settings.caption.prompt_ask = $('#caption_prompt_ask').prop('checked');
saveSettingsDebounced();
});
$('#caption_auto_mode').on('input', () => {
extension_settings.caption.auto_mode = !!$('#caption_auto_mode').prop('checked');
saveSettingsDebounced();
});
const onMessageEvent = async (index) => {
if (!extension_settings.caption.auto_mode) {
return;
}
const data = getContext().chat[index];
await captionExistingMessage(data);
};
eventSource.on(event_types.MESSAGE_SENT, onMessageEvent);
eventSource.on(event_types.MESSAGE_FILE_EMBEDDED, onMessageEvent);
$(document).on('click', '.mes_img_caption', async function () {
const animationClass = 'fa-fade';
const messageBlock = $(this).closest('.mes');
const messageImg = messageBlock.find('.mes_img');
if (messageImg.hasClass(animationClass)) return;
messageImg.addClass(animationClass);
try {
const index = Number(messageBlock.attr('mesid'));
const data = getContext().chat[index];
await captionExistingMessage(data);
appendMediaToMessage(data, messageBlock, false);
await saveChatConditional();
} catch(e) {
console.error('Message image recaption failed', e);
} finally {
messageImg.removeClass(animationClass);
}
});
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'caption',
callback: captionCommandCallback,
@@ -482,4 +567,6 @@ jQuery(async function () {
</div>
`,
}));
document.body.classList.add('caption');
});

View File

@@ -26,6 +26,7 @@
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
<option value="ooba" data-i18n="Text Generation WebUI (oobabooga)">Text Generation WebUI (oobabooga)</option>
<option value="vllm">vLLM</option>
</select>
</div>
<div class="flex1 flex-container flexFlowColumn flexNoGap">
@@ -66,6 +67,7 @@
<option data-type="llamacpp" value="llamacpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="ooba" value="ooba_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option>
<option data-type="custom" value="custom_current" data-i18n="currently_selected">[Currently selected]</option>
</select>
</div>
@@ -82,14 +84,19 @@
</div>
<div id="caption_prompt_block">
<label for="caption_prompt" data-i18n="Caption Prompt">Caption Prompt</label>
<textarea id="caption_prompt" class="text_pole" rows="1" placeholder="&lt; Use default &gt;">${PROMPT_DEFAULT}</textarea>
<textarea id="caption_prompt" class="text_pole" rows="1" placeholder="&lt; Use default &gt;">{{PROMPT_DEFAULT}}</textarea>
<label class="checkbox_label margin-bot-10px" for="caption_prompt_ask" title="Ask for a custom prompt every time an image is captioned.">
<input id="caption_prompt_ask" type="checkbox" class="checkbox">
<span data-i18n="Ask every time">Ask every time</span>
</label>
</div>
<label for="caption_template"><span data-i18n="Message Template">Message Template</span> <small><span data-i18n="(use _space">(use </span> <code>&lcub;&lcub;caption&rcub;&rcub;</code> <span data-i18n="macro)">macro)</span></small></label>
<textarea id="caption_template" class="text_pole" rows="2" placeholder="&lt; Use default &gt;">${TEMPLATE_DEFAULT}</textarea>
<textarea id="caption_template" class="text_pole" rows="2" placeholder="&lt; Use default &gt;">{{TEMPLATE_DEFAULT}}</textarea>
<label class="checkbox_label" for="caption_auto_mode">
<input id="caption_auto_mode" type="checkbox" class="checkbox">
<span data-i18n="Automatically caption images">Automatically caption images</span>
<i class="fa-solid fa-info-circle" title="Automatically caption images when they are pasted into the chat or attached to messages."></i>
</label>
<label class="checkbox_label margin-bot-10px" for="caption_refine_mode">
<input id="caption_refine_mode" type="checkbox" class="checkbox">
<span data-i18n="Edit captions before saving">Edit captions before saving</span>

View File

@@ -447,7 +447,7 @@ async function summarizeCallback(args, text) {
}
const source = args.source || extension_settings.memory.source;
const prompt = substituteParamsExtended((resolveVariable(args.prompt) || extension_settings.memory.prompt), { words: extension_settings.memory.promptWords });
const prompt = substituteParamsExtended((args.prompt || extension_settings.memory.prompt), { words: extension_settings.memory.promptWords });
try {
switch (source) {
@@ -923,10 +923,8 @@ jQuery(async function () {
SlashCommandNamedArgument.fromProps({
name: 'prompt',
description: 'prompt to use for summarization',
typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME],
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: '',
enumProvider: commonEnumProviders.variables('all'),
forceEnum: false,
}),
],
unnamedArgumentList: [

View File

@@ -231,6 +231,7 @@ export class QuickReplySet {
this.rerender();
} else {
warn(`Failed to save Quick Reply Set: ${this.name}`);
console.error('QR could not be saved', response);
}
}

View File

@@ -349,7 +349,7 @@ function migrateSettings() {
/**
* /regex slash command callback
* @param {object} args Named arguments
* @param {{name: string}} args Named arguments
* @param {string} value Unnamed argument
* @returns {string} The regexed string
*/
@@ -359,11 +359,11 @@ function runRegexCallback(args, value) {
return value;
}
const scriptName = String(resolveVariable(args.name));
const scriptName = args.name;
const scripts = getRegexScripts();
for (const script of scripts) {
if (String(script.scriptName).toLowerCase() === String(scriptName).toLowerCase()) {
if (script.scriptName.toLowerCase() === scriptName.toLowerCase()) {
if (script.disabled) {
toastr.warning(`Regex script "${scriptName}" is disabled.`);
return value;
@@ -588,7 +588,7 @@ jQuery(async () => {
SlashCommandNamedArgument.fromProps({
name: 'name',
description: 'script name',
typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME],
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.regexScripts,
}),

View File

@@ -34,6 +34,7 @@ export async function getMultimodalCaption(base64Img, prompt) {
const isCustom = extension_settings.caption.multimodal_api === 'custom';
const isOoba = extension_settings.caption.multimodal_api === 'ooba';
const isKoboldCpp = extension_settings.caption.multimodal_api === 'koboldcpp';
const isVllm = extension_settings.caption.multimodal_api === 'vllm';
const base64Bytes = base64Img.length * 0.75;
const compressionLimit = 2 * 1024 * 1024;
if ((['google', 'openrouter'].includes(extension_settings.caption.multimodal_api) && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) {
@@ -65,6 +66,14 @@ export async function getMultimodalCaption(base64Img, prompt) {
requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.OLLAMA];
}
if (isVllm) {
if (extension_settings.caption.multimodal_model === 'vllm_current') {
requestBody.model = textgenerationwebui_settings.vllm_model;
}
requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.VLLM];
}
if (isLlamaCpp) {
requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP];
}
@@ -151,6 +160,14 @@ function throwIfInvalidModel(useReverseProxy) {
throw new Error('KoboldCpp server URL is not set.');
}
if (extension_settings.caption.multimodal_api === 'vllm' && !textgenerationwebui_settings.server_urls[textgen_types.VLLM]) {
throw new Error('vLLM server URL is not set.');
}
if (extension_settings.caption.multimodal_api === 'vllm' && extension_settings.caption.multimodal_model === 'vllm_current' && !textgenerationwebui_settings.vllm_model) {
throw new Error('vLLM model is not set.');
}
if (extension_settings.caption.multimodal_api === 'custom' && !oai_settings.custom_url) {
throw new Error('Custom API URL is not set.');
}

View File

@@ -3,7 +3,6 @@ import {
systemUserName,
hideSwipeButtons,
showSwipeButtons,
callPopup,
getRequestHeaders,
event_types,
eventSource,
@@ -23,16 +22,15 @@ import { getApiUrl, getContext, extension_settings, doExtrasFetch, modules, rend
import { selected_group } from '../../group-chats.js';
import { stringFormat, initScrollHeight, resetScrollHeight, getCharaFilename, saveBase64AsFile, getBase64Async, delay, isTrueBoolean, debounce } from '../../utils.js';
import { getMessageTimeStamp, humanizedDateTime } from '../../RossAscends-mods.js';
import { SECRET_KEYS, secret_state } from '../../secrets.js';
import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js';
import { getNovelUnlimitedImageGeneration, getNovelAnlas, loadNovelSubscriptionData } from '../../nai-settings.js';
import { getMultimodalCaption } from '../shared.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { resolveVariable } from '../../variables.js';
import { debounce_timeout } from '../../constants.js';
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
export { MODULE_NAME };
const MODULE_NAME = 'sd';
@@ -51,6 +49,7 @@ const sources = {
togetherai: 'togetherai',
drawthings: 'drawthings',
pollinations: 'pollinations',
stability: 'stability',
};
const initiators = {
@@ -173,6 +172,8 @@ const defaultStyles = [
},
];
const placeholderVae = 'Automatic';
const defaultSettings = {
source: sources.extras,
@@ -284,6 +285,9 @@ const defaultSettings = {
wand_visible: false,
command_visible: false,
interactive_visible: false,
// Stability AI settings
stability_style_preset: 'anime',
};
const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);
@@ -446,6 +450,7 @@ async function loadSettings() {
$('#sd_wand_visible').prop('checked', extension_settings.sd.wand_visible);
$('#sd_command_visible').prop('checked', extension_settings.sd.command_visible);
$('#sd_interactive_visible').prop('checked', extension_settings.sd.interactive_visible);
$('#sd_stability_style_preset').val(extension_settings.sd.stability_style_preset);
for (const style of extension_settings.sd.styles) {
const option = document.createElement('option');
@@ -572,7 +577,7 @@ async function onDeleteStyleClick() {
return;
}
const confirmed = await callPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, 'confirm', '', { okButton: 'Delete' });
const confirmed = await callGenericPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
if (!confirmed) {
return;
@@ -601,7 +606,7 @@ async function onDeleteStyleClick() {
}
async function onSaveStyleClick() {
const userInput = await callPopup('Enter style name:', 'input', '', { okButton: 'Save' });
const userInput = await callGenericPopup('Enter style name:', POPUP_TYPE.INPUT);
if (!userInput) {
return;
@@ -670,10 +675,10 @@ async function refinePrompt(prompt, allowExpand, isNegative = false) {
if (extension_settings.sd.refine_mode) {
const text = isNegative ? '<h3>Review and edit the <i>negative</i> prompt:</h3>' : '<h3>Review and edit the prompt:</h3>';
const refinedPrompt = await callPopup(text + 'Press "Cancel" to abort the image generation.', 'input', prompt.trim(), { rows: 5, okButton: 'Continue' });
const refinedPrompt = await callGenericPopup(text + 'Press "Cancel" to abort the image generation.', POPUP_TYPE.INPUT, prompt.trim(), { rows: 5, okButton: 'Continue' });
if (refinedPrompt) {
return refinedPrompt;
return String(refinedPrompt);
} else {
throw new Error('Generation aborted by user.');
}
@@ -928,6 +933,7 @@ async function onSourceChange() {
extension_settings.sd.model = null;
extension_settings.sd.sampler = null;
extension_settings.sd.scheduler = null;
extension_settings.sd.vae = null;
toggleSourceControls();
saveSettingsDebounced();
await loadSettingOptions();
@@ -1086,6 +1092,26 @@ function onComfyWorkflowChange() {
extension_settings.sd.comfy_workflow = $('#sd_comfy_workflow').find(':selected').val();
saveSettingsDebounced();
}
async function onStabilityKeyClick() {
const popupText = 'Stability AI API Key:';
const key = await callGenericPopup(popupText, POPUP_TYPE.INPUT);
if (!key) {
return;
}
await writeSecret(SECRET_KEYS.STABILITY, String(key));
toastr.success('API Key saved');
await loadSettingOptions();
}
function onStabilityStylePresetChange() {
extension_settings.sd.stability_style_preset = String($('#sd_stability_style_preset').val());
saveSettingsDebounced();
}
async function changeComfyWorkflow(_, name) {
name = name.replace(/(\.json)?$/i, '.json');
if ($(`#sd_comfy_workflow > [value="${name}"]`).length > 0) {
@@ -1195,7 +1221,7 @@ async function onModelChange() {
extension_settings.sd.model = $('#sd_model').find(':selected').val();
saveSettingsDebounced();
const cloudSources = [sources.horde, sources.novel, sources.openai, sources.togetherai, sources.pollinations];
const cloudSources = [sources.horde, sources.novel, sources.openai, sources.togetherai, sources.pollinations, sources.stability];
if (cloudSources.includes(extension_settings.sd.source)) {
return;
@@ -1404,6 +1430,9 @@ async function loadSamplers() {
case sources.pollinations:
samplers = ['N/A'];
break;
case sources.stability:
samplers = ['N/A'];
break;
}
for (const sampler of samplers) {
@@ -1587,6 +1616,9 @@ async function loadModels() {
case sources.pollinations:
models = await loadPollinationsModels();
break;
case sources.stability:
models = await loadStabilityModels();
break;
}
for (const model of models) {
@@ -1603,6 +1635,16 @@ async function loadModels() {
}
}
async function loadStabilityModels() {
$('#sd_stability_key').toggleClass('success', !!secret_state[SECRET_KEYS.STABILITY]);
return [
{ value: 'stable-image-ultra', text: 'Stable Image Ultra' },
{ value: 'stable-image-core', text: 'Stable Image Core' },
{ value: 'stable-diffusion-3', text: 'Stable Diffusion 3' },
];
}
async function loadPollinationsModels() {
return [
{
@@ -1934,6 +1976,9 @@ async function loadSchedulers() {
case sources.comfy:
schedulers = await loadComfySchedulers();
break;
case sources.stability:
schedulers = ['N/A'];
break;
}
for (const scheduler of schedulers) {
@@ -1984,7 +2029,7 @@ async function loadVaes() {
vaes = ['N/A'];
break;
case sources.auto:
vaes = ['N/A'];
vaes = await loadAutoVaes();
break;
case sources.novel:
vaes = ['N/A'];
@@ -2007,6 +2052,9 @@ async function loadVaes() {
case sources.comfy:
vaes = await loadComfyVaes();
break;
case sources.stability:
vaes = ['N/A'];
break;
}
for (const vae of vaes) {
@@ -2016,6 +2064,35 @@ async function loadVaes() {
option.selected = vae === extension_settings.sd.vae;
$('#sd_vae').append(option);
}
if (!extension_settings.sd.vae && vaes.length > 0 && vaes[0] !== 'N/A') {
extension_settings.sd.vae = vaes[0];
$('#sd_vae').val(extension_settings.sd.vae).trigger('change');
}
}
async function loadAutoVaes() {
if (!extension_settings.sd.auto_url) {
return ['N/A'];
}
try {
const result = await fetch('/api/sd/vaes', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(getSdRequestBody()),
});
if (!result.ok) {
throw new Error('SD WebUI returned an error.');
}
const data = await result.json();
Array.isArray(data) && data.unshift(placeholderVae);
return data;
} catch (error) {
return ['N/A'];
}
}
async function loadComfyVaes() {
@@ -2206,7 +2283,7 @@ async function generatePicture(initiator, args, trigger, message, callback) {
}
const dimensions = setTypeSpecificDimensions(generationType);
let negativePromptPrefix = resolveVariable(args?.negative) || '';
let negativePromptPrefix = args?.negative || '';
let imagePath = '';
try {
@@ -2487,6 +2564,9 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
case sources.pollinations:
result = await generatePollinationsImage(prefixedPrompt, negativePrompt);
break;
case sources.stability:
result = await generateStabilityImage(prefixedPrompt, negativePrompt);
break;
}
if (!result.data) {
@@ -2510,6 +2590,12 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP
return base64Image;
}
/**
* Generates an image using the TogetherAI API.
* @param {string} prompt - The main instruction used to guide the image generation.
* @param {string} negativePrompt - The instruction used to restrict the image generation.
* @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
*/
async function generateTogetherAIImage(prompt, negativePrompt) {
const result = await fetch('/api/sd/together/generate', {
method: 'POST',
@@ -2534,6 +2620,12 @@ async function generateTogetherAIImage(prompt, negativePrompt) {
}
}
/**
* Generates an image using the Pollinations API.
* @param {string} prompt - The main instruction used to guide the image generation.
* @param {string} negativePrompt - The instruction used to restrict the image generation.
* @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
*/
async function generatePollinationsImage(prompt, negativePrompt) {
const result = await fetch('/api/sd/pollinations/generate', {
method: 'POST',
@@ -2602,6 +2694,84 @@ async function generateExtrasImage(prompt, negativePrompt) {
}
}
/**
* Gets an aspect ratio for Stability that is the closest to the given width and height.
* @param {number} width Target width
* @param {number} height Target height
* @returns {string} Closest aspect ratio as a string
*/
function getClosestAspectRatio(width, height) {
const aspectRatios = {
'16:9': 16 / 9,
'1:1': 1,
'21:9': 21 / 9,
'2:3': 2 / 3,
'3:2': 3 / 2,
'4:5': 4 / 5,
'5:4': 5 / 4,
'9:16': 9 / 16,
'9:21': 9 / 21,
};
const aspectRatio = width / height;
let closestAspectRatio = Object.keys(aspectRatios)[0];
let minDiff = Math.abs(aspectRatio - aspectRatios[closestAspectRatio]);
for (const key in aspectRatios) {
const diff = Math.abs(aspectRatio - aspectRatios[key]);
if (diff < minDiff) {
minDiff = diff;
closestAspectRatio = key;
}
}
return closestAspectRatio;
}
/**
* Generates an image using Stability AI.
* @param {string} prompt - The main instruction used to guide the image generation.
* @param {string} negativePrompt - The instruction used to restrict the image generation.
* @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
*/
async function generateStabilityImage(prompt, negativePrompt) {
const IMAGE_FORMAT = 'png';
const PROMPT_LIMIT = 10000;
try {
const response = await fetch('/api/sd/stability/generate', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
model: extension_settings.sd.model,
payload: {
prompt: prompt.slice(0, PROMPT_LIMIT),
negative_prompt: negativePrompt.slice(0, PROMPT_LIMIT),
aspect_ratio: getClosestAspectRatio(extension_settings.sd.width, extension_settings.sd.height),
seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined,
style_preset: extension_settings.sd.stability_style_preset,
output_format: IMAGE_FORMAT,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const base64Image = await response.text();
return {
format: IMAGE_FORMAT,
data: base64Image,
};
} catch (error) {
console.error('Error generating image with Stability AI:', error);
throw error;
}
}
/**
* Generates a "horde" image using the provided prompt and configuration settings.
*
@@ -2648,6 +2818,7 @@ async function generateHordeImage(prompt, negativePrompt) {
* @returns {Promise<{format: string, data: string}>} - A promise that resolves when the image generation and processing are complete.
*/
async function generateAutoImage(prompt, negativePrompt) {
const isValidVae = extension_settings.sd.vae && !['N/A', placeholderVae].includes(extension_settings.sd.vae);
const result = await fetch('/api/sd/generate', {
method: 'POST',
headers: getRequestHeaders(),
@@ -2671,6 +2842,7 @@ async function generateAutoImage(prompt, negativePrompt) {
// For AUTO1111
override_settings: {
CLIP_stop_at_last_layers: extension_settings.sd.clip_skip,
sd_vae: isValidVae ? extension_settings.sd.vae : undefined,
},
override_settings_restore_afterwards: true,
// For SD.Next
@@ -2918,25 +3090,25 @@ async function generateComfyImage(prompt, negativePrompt) {
const text = await workflowResponse.text();
toastr.error(`Failed to load workflow.\n\n${text}`);
}
let workflow = (await workflowResponse.json()).replace('"%prompt%"', JSON.stringify(prompt));
workflow = workflow.replace('"%negative_prompt%"', JSON.stringify(negativePrompt));
let workflow = (await workflowResponse.json()).replaceAll('"%prompt%"', JSON.stringify(prompt));
workflow = workflow.replaceAll('"%negative_prompt%"', JSON.stringify(negativePrompt));
const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed));
placeholders.forEach(ph => {
workflow = workflow.replace(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
workflow = workflow.replaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
});
(extension_settings.sd.comfy_placeholders ?? []).forEach(ph => {
workflow = workflow.replace(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace)));
workflow = workflow.replaceAll(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace)));
});
if (/%user_avatar%/gi.test(workflow)) {
const response = await fetch(getUserAvatarUrl());
if (response.ok) {
const avatarBlob = await response.blob();
const avatarBase64 = await getBase64Async(avatarBlob);
workflow = workflow.replace('"%user_avatar%"', JSON.stringify(avatarBase64));
workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(avatarBase64));
} else {
workflow = workflow.replace('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
}
}
if (/%char_avatar%/gi.test(workflow)) {
@@ -2944,9 +3116,9 @@ async function generateComfyImage(prompt, negativePrompt) {
if (response.ok) {
const avatarBlob = await response.blob();
const avatarBase64 = await getBase64Async(avatarBlob);
workflow = workflow.replace('"%char_avatar%"', JSON.stringify(avatarBase64));
workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(avatarBase64));
} else {
workflow = workflow.replace('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
}
}
console.log(`{
@@ -2978,7 +3150,12 @@ async function onComfyOpenWorkflowEditorClick() {
}),
})).json();
const editorHtml = $(await $.get('scripts/extensions/stable-diffusion/comfyWorkflowEditor.html'));
const popupResult = callPopup(editorHtml, 'confirm', undefined, { okButton: 'Save', wide: true, large: true, rows: 1 });
const saveValue = (/** @type {Popup} */ _popup) => {
workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();
return true;
};
const popup = new Popup(editorHtml, POPUP_TYPE.CONFIRM, '', { okButton: 'Save', cancelButton: 'Cancel', wide: true, large: true, onClosing: saveValue });
const popupResult = popup.show();
const checkPlaceholders = () => {
workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();
$('.sd_comfy_workflow_editor_placeholder_list > li[data-placeholder]').each(function (idx) {
@@ -3047,7 +3224,7 @@ async function onComfyOpenWorkflowEditorClick() {
headers: getRequestHeaders(),
body: JSON.stringify({
file_name: extension_settings.sd.comfy_workflow,
workflow: $('#sd_comfy_workflow_editor_workflow').val().toString(),
workflow: workflow,
}),
});
if (!response.ok) {
@@ -3058,7 +3235,7 @@ async function onComfyOpenWorkflowEditorClick() {
}
async function onComfyNewWorkflowClick() {
let name = await callPopup('<h3>Workflow name:</h3>', 'input');
let name = await callGenericPopup('Workflow name:', POPUP_TYPE.INPUT);
if (!name) {
return;
}
@@ -3085,7 +3262,7 @@ async function onComfyNewWorkflowClick() {
}
async function onComfyDeleteWorkflowClick() {
const confirm = await callPopup('Delete the workflow? This action is irreversible.', 'confirm');
const confirm = await callGenericPopup('Delete the workflow? This action is irreversible.', POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
if (!confirm) {
return;
}
@@ -3230,6 +3407,8 @@ function isValidState() {
return secret_state[SECRET_KEYS.TOGETHERAI];
case sources.pollinations:
return true;
case sources.stability:
return secret_state[SECRET_KEYS.STABILITY];
}
}
@@ -3356,8 +3535,7 @@ jQuery(async () => {
SlashCommandNamedArgument.fromProps({
name: 'negative',
description: 'negative prompt prefix',
typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME],
enumProvider: commonEnumProviders.variables('all'),
typeList: [ARGUMENT_TYPE.STRING],
}),
],
unnamedArgumentList: [
@@ -3458,6 +3636,8 @@ jQuery(async () => {
$('#sd_command_visible').on('input', onCommandVisibleInput);
$('#sd_interactive_visible').on('input', onInteractiveVisibleInput);
$('#sd_swap_dimensions').on('click', onSwapDimensionsClick);
$('#sd_stability_key').on('click', onStabilityKeyClick);
$('#sd_stability_style_preset').on('change', onStabilityStylePresetChange);
$('.sd_settings .inline-drawer-toggle').on('click', function () {
initScrollHeight($('#sd_prompt_prefix'));

View File

@@ -44,6 +44,7 @@
<option value="openai">OpenAI (DALL-E)</option>
<option value="pollinations">Pollinations</option>
<option value="vlad">SD.Next (vladmandic)</option>
<option value="stability">Stability AI</option>
<option value="auto">Stable Diffusion Web UI (AUTOMATIC1111)</option>
<option value="horde">Stable Horde</option>
<option value="togetherai">TogetherAI</option>
@@ -189,21 +190,59 @@
</label>
</div>
</div>
<div data-sd-source="stability">
<div class="flex-container flexnowrap alignItemsBaseline marginBot5">
<strong class="flex1" data-i18n="API Key">API Key</strong>
<div id="sd_stability_key" class="menu_button menu_button_icon">
<i class="fa-fw fa-solid fa-key"></i>
<span data-i18n="Click to set">Click to set</span>
</div>
</div>
<div class="marginBot5">
<i data-i18n="You can find your API key in the Stability AI dashboard.">
You can find your API key in the Stability AI dashboard.
</i>
</div>
<div class="flex-container">
<div class="flex1">
<label for="sd_stability_style_preset" data-i18n="Style Preset">Style Preset</label>
<select id="sd_stability_style_preset">
<option value="anime">Anime</option>
<option value="3d-model">3D Model</option>
<option value="analog-film">Analog Film</option>
<option value="cinematic">Cinematic</option>
<option value="comic-book">Comic Book</option>
<option value="digital-art">Digital Art</option>
<option value="enhance">Enhance</option>
<option value="fantasy-art">Fantasy Art</option>
<option value="isometric">Isometric</option>
<option value="line-art">Line Art</option>
<option value="low-poly">Low Poly</option>
<option value="modeling-compound">Modeling Compound</option>
<option value="neon-punk">Neon Punk</option>
<option value="origami">Origami</option>
<option value="photographic">Photographic</option>
<option value="pixel-art">Pixel Art</option>
<option value="tile-texture">Tile Texture</option>
</select>
</div>
</div>
</div>
<div class="flex-container">
<div class="flex1">
<label for="sd_model" data-i18n="Model">Model</label>
<select id="sd_model"></select>
</div>
<div class="flex1" data-sd-source="comfy">
<div class="flex1" data-sd-source="comfy,auto">
<label for="sd_vae">VAE</label>
<select id="sd_vae"></select>
</div>
</div>
<div class="flex-container">
<div class="flex1">
<div class="flex1" data-sd-source="extras,horde,auto,drawthings,novel,vlad,comfy">
<label for="sd_sampler" data-i18n="Sampling method">Sampling method</label>
<select id="sd_sampler"></select>
</div>
@@ -339,7 +378,7 @@
</label>
</div>
<div data-sd-source="novel,togetherai,pollinations,comfy,drawthings,vlad,auto,horde,extras" class="marginTop5">
<div data-sd-source="novel,togetherai,pollinations,comfy,drawthings,vlad,auto,horde,extras,stability" class="marginTop5">
<label for="sd_seed">
<span data-i18n="Seed">Seed</span>
<small data-i18n="(-1 for random)">(-1 for random)</small>

View File

@@ -1,4 +1,5 @@
import { callPopup, getRequestHeaders } from '../../../script.js';
import { getRequestHeaders } from '../../../script.js';
import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
import { SECRET_KEYS, findSecret, secret_state, writeSecret } from '../../secrets.js';
import { getPreviewString, saveTtsProviderSettings } from './index.js';
export { AzureTtsProvider };
@@ -69,13 +70,13 @@ class AzureTtsProvider {
const popupText = 'Azure TTS API Key';
const savedKey = secret_state[SECRET_KEYS.AZURE_TTS] ? await findSecret(SECRET_KEYS.AZURE_TTS) : '';
const key = await callPopup(popupText, 'input', savedKey);
const key = await callGenericPopup(popupText, POPUP_TYPE.INPUT, savedKey);
if (key == false || key == '') {
return;
}
await writeSecret(SECRET_KEYS.AZURE_TTS, key);
await writeSecret(SECRET_KEYS.AZURE_TTS, String(key));
toastr.success('API Key saved');
$('#azure_tts_key').addClass('success');

View File

@@ -5,8 +5,8 @@ TODO:
*/
import { doExtrasFetch, extension_settings, getApiUrl, modules } from '../../extensions.js';
import { callPopup } from '../../../script.js';
import { initVoiceMap } from './index.js';
import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
export { CoquiTtsProvider };
@@ -246,7 +246,7 @@ class CoquiTtsProvider {
}
// Ask user for voiceId name to save voice
const voiceName = await callPopup('<h3>Name of Coqui voice to add to voice select dropdown:</h3>', 'input');
const voiceName = await callGenericPopup('Name of Coqui voice to add to voice select dropdown:', POPUP_TYPE.INPUT);
const model_origin = $('#coqui_model_origin').val();
const model_language = $('#coqui_api_language').val();

View File

@@ -1,4 +1,4 @@
import { cancelTtsPlay, eventSource, event_types, isStreamingEnabled, name2, saveSettingsDebounced, substituteParams } from '../../../script.js';
import { cancelTtsPlay, eventSource, event_types, getCurrentChatId, isStreamingEnabled, name2, saveSettingsDebounced, substituteParams } from '../../../script.js';
import { ModuleWorkerWrapper, doExtrasFetch, extension_settings, getApiUrl, getContext, modules, renderExtensionTemplateAsync } from '../../extensions.js';
import { delay, escapeRegex, getBase64Async, getStringHash, onlyUnique } from '../../utils.js';
import { EdgeTtsProvider } from './edge.js';
@@ -10,6 +10,7 @@ import { NovelTtsProvider } from './novel.js';
import { power_user } from '../../power-user.js';
import { OpenAITtsProvider } from './openai.js';
import { XTTSTtsProvider } from './xtts.js';
import { VITSTtsProvider } from './vits.js';
import { GSVITtsProvider } from './gsvi.js';
import { SBVits2TtsProvider } from './sbvits2.js';
import { AllTalkTtsProvider } from './alltalk.js';
@@ -34,6 +35,7 @@ let lastMessage = null;
let lastMessageHash = null;
let periodicMessageGenerationTimer = null;
let lastPositionOfParagraphEnd = -1;
let currentInitVoiceMapPromise = null;
const DEFAULT_VOICE_MARKER = '[Default Voice]';
const DISABLED_VOICE_MARKER = 'disabled';
@@ -83,6 +85,7 @@ const ttsProviders = {
ElevenLabs: ElevenLabsTtsProvider,
Silero: SileroTtsProvider,
XTTSv2: XTTSTtsProvider,
VITS: VITSTtsProvider,
GSVI: GSVITtsProvider,
SBVits2: SBVits2TtsProvider,
System: SystemTtsProvider,
@@ -1008,9 +1011,39 @@ class VoiceMapEntry {
/**
* Init voiceMapEntries for character select list.
* If an initialization is already in progress, it returns the existing Promise instead of starting a new one.
* @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
* @returns {Promise} A promise that resolves when the initialization is complete.
*/
export async function initVoiceMap(unrestricted = false) {
// Preventing parallel execution
if (currentInitVoiceMapPromise) {
return currentInitVoiceMapPromise;
}
currentInitVoiceMapPromise = (async () => {
const initialChatId = getCurrentChatId();
try {
await initVoiceMapInternal(unrestricted);
} finally {
currentInitVoiceMapPromise = null;
}
const currentChatId = getCurrentChatId();
if (initialChatId !== currentChatId) {
// Chat changed during initialization, reinitialize
await initVoiceMap(unrestricted);
}
})();
return currentInitVoiceMapPromise;
}
/**
* Init voiceMapEntries for character select list.
* @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
*/
async function initVoiceMapInternal(unrestricted) {
// Gate initialization if not enabled or TTS Provider not ready. Prevents error popups.
const enabled = $('#tts_enabled').is(':checked');
if (!enabled) {

View File

@@ -1,4 +1,5 @@
import { getRequestHeaders, callPopup } from '../../../script.js';
import { getRequestHeaders } from '../../../script.js';
import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
import { splitRecursive } from '../../utils.js';
import { getPreviewString, saveTtsProviderSettings } from './index.js';
import { initVoiceMap } from './index.js';
@@ -56,7 +57,7 @@ class NovelTtsProvider {
// Add a new Novel custom voice to provider
async addCustomVoice() {
const voiceName = await callPopup('<h3>Custom Voice name:</h3>', 'input');
const voiceName = await callGenericPopup('Custom Voice name:', POPUP_TYPE.INPUT);
this.settings.customVoices.push(voiceName);
this.populateCustomVoices();
initVoiceMap(); // Update TTS extension voiceMap

View File

@@ -0,0 +1,404 @@
import { getPreviewString, saveTtsProviderSettings } from './index.js';
export { VITSTtsProvider };
class VITSTtsProvider {
//########//
// Config //
//########//
settings;
ready = false;
voices = [];
separator = '. ';
audioElement = document.createElement('audio');
/**
* Perform any text processing before passing to TTS engine.
* @param {string} text Input text
* @returns {string} Processed text
*/
processText(text) {
return text;
}
audioFormats = ['wav', 'ogg', 'silk', 'mp3', 'flac'];
languageLabels = {
'Auto': 'auto',
'Chinese': 'zh',
'English': 'en',
'Japanese': 'ja',
'Korean': 'ko',
};
langKey2LangCode = {
'zh': 'zh-CN',
'en': 'en-US',
'ja': 'ja-JP',
'ko': 'ko-KR',
};
modelTypes = {
VITS: 'VITS',
W2V2_VITS: 'W2V2-VITS',
BERT_VITS2: 'BERT-VITS2',
};
defaultSettings = {
provider_endpoint: 'http://localhost:23456',
format: 'wav',
lang: 'auto',
length: 1.0,
noise: 0.33,
noisew: 0.4,
segment_size: 50,
streaming: false,
dim_emotion: 0,
sdp_ratio: 0.2,
emotion: 0,
text_prompt: '',
style_text: '',
style_weight: 1,
};
get settingsHtml() {
let html = `
<label for="vits_lang">Text Language</label>
<select id="vits_lang">`;
for (let language in this.languageLabels) {
if (this.languageLabels[language] == this.settings?.lang) {
html += `<option value="${this.languageLabels[language]}" selected="selected">${language}</option>`;
continue;
}
html += `<option value="${this.languageLabels[language]}">${language}</option>`;
}
html += `
</select>
<label>VITS / W2V2-VITS / Bert-VITS2 Settings:</label><br/>
<label for="vits_endpoint">Provider Endpoint:</label>
<input id="vits_endpoint" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.provider_endpoint}"/>
<span>Use <a target="_blank" href="https://github.com/Artrajz/vits-simple-api">vits-simple-api</a>.</span><br/>
<label for="vits_format">Audio format:</label>
<select id="vits_format">`;
for (let format of this.audioFormats) {
if (format == this.settings?.format) {
html += `<option value="${format}" selected="selected">${format}</option>`;
continue;
}
html += `<option value="${format}">${format}</option>`;
}
html += `
</select>
<label for="vits_length">Audio length: <span id="vits_length_output">${this.defaultSettings.length}</span></label>
<input id="vits_length" type="range" value="${this.defaultSettings.length}" min="0.0" max="5" step="0.01" />
<label for="vits_noise">Noise: <span id="vits_noise_output">${this.defaultSettings.noise}</span></label>
<input id="vits_noise" type="range" value="${this.defaultSettings.noise}" min="0.1" max="2" step="0.01" />
<label for="vits_noisew">SDP noise: <span id="vits_noisew_output">${this.defaultSettings.noisew}</span></label>
<input id="vits_noisew" type="range" value="${this.defaultSettings.noisew}" min="0.1" max="2" step="0.01" />
<label for="vits_segment_size">Segment Size: <span id="vits_segment_size_output">${this.defaultSettings.segment_size}</span></label>
<input id="vits_segment_size" type="range" value="${this.defaultSettings.segment_size}" min="0" max="1000" step="1" />
<label for="vits_streaming" class="checkbox_label">
<input id="vits_streaming" type="checkbox" />
<span>Streaming</span>
</label>
<label>W2V2-VITS Settings:</label><br/>
<label for="vits_dim_emotion">Dimensional emotion:</label>
<input id="vits_dim_emotion" type="number" class="text_pole" min="0" max="5457" step="1" value="${this.defaultSettings.dim_emotion}"/>
<label>BERT-VITS2 Settings:</label><br/>
<label for="vits_sdp_ratio">sdp_ratio: <span id="vits_sdp_ratio_output">${this.defaultSettings.sdp_ratio}</span></label>
<input id="vits_sdp_ratio" type="range" value="${this.defaultSettings.sdp_ratio}" min="0.0" max="1" step="0.01" />
<label for="vits_emotion">emotion: <span id="vits_emotion_output">${this.defaultSettings.emotion}</span></label>
<input id="vits_emotion" type="range" value="${this.defaultSettings.emotion}" min="0" max="9" step="1" />
<label for="vits_text_prompt">Text Prompt:</label>
<input id="vits_text_prompt" type="text" class="text_pole" maxlength="512" value="${this.defaultSettings.text_prompt}"/>
<label for="vits_style_text">Style text:</label>
<input id="vits_style_text" type="text" class="text_pole" maxlength="512" value="${this.defaultSettings.style_text}"/>
<label for="vits_style_weight">Style weight <span id="vits_style_weight_output">${this.defaultSettings.style_weight}</span></label>
<input id="vits_style_weight" type="range" value="${this.defaultSettings.style_weight}" min="0" max="1" step="0.01" />
`;
return html;
}
onSettingsChange() {
// Used when provider settings are updated from UI
this.settings.provider_endpoint = $('#vits_endpoint').val();
this.settings.lang = $('#vits_lang').val();
this.settings.format = $('#vits_format').val();
this.settings.dim_emotion = $('#vits_dim_emotion').val();
this.settings.text_prompt = $('#vits_text_prompt').val();
this.settings.style_text = $('#vits_style_text').val();
// Update the default TTS settings based on input fields
this.settings.length = $('#vits_length').val();
this.settings.noise = $('#vits_noise').val();
this.settings.noisew = $('#vits_noisew').val();
this.settings.segment_size = $('#vits_segment_size').val();
this.settings.streaming = $('#vits_streaming').is(':checked');
this.settings.sdp_ratio = $('#vits_sdp_ratio').val();
this.settings.emotion = $('#vits_emotion').val();
this.settings.style_weight = $('#vits_style_weight').val();
// Update the UI to reflect changes
$('#vits_length_output').text(this.settings.length);
$('#vits_noise_output').text(this.settings.noise);
$('#vits_noisew_output').text(this.settings.noisew);
$('#vits_segment_size_output').text(this.settings.segment_size);
$('#vits_sdp_ratio_output').text(this.settings.sdp_ratio);
$('#vits_emotion_output').text(this.settings.emotion);
$('#vits_style_weight_output').text(this.settings.style_weight);
saveTtsProviderSettings();
this.changeTTSSettings();
}
async loadSettings(settings) {
// Pupulate Provider UI given input settings
if (Object.keys(settings).length == 0) {
console.info('Using default TTS Provider settings');
}
// Only accept keys defined in defaultSettings
this.settings = this.defaultSettings;
for (const key in settings) {
if (key in this.settings) {
this.settings[key] = settings[key];
} else {
console.debug(`Ignoring non-user-configurable setting: ${key}`);
}
}
// Set initial values from the settings
$('#vits_endpoint').val(this.settings.provider_endpoint);
$('#vits_lang').val(this.settings.lang);
$('#vits_format').val(this.settings.format);
$('#vits_length').val(this.settings.length);
$('#vits_noise').val(this.settings.noise);
$('#vits_noisew').val(this.settings.noisew);
$('#vits_segment_size').val(this.settings.segment_size);
$('#vits_streaming').prop('checked', this.settings.streaming);
$('#vits_dim_emotion').val(this.settings.dim_emotion);
$('#vits_sdp_ratio').val(this.settings.sdp_ratio);
$('#vits_emotion').val(this.settings.emotion);
$('#vits_text_prompt').val(this.settings.text_prompt);
$('#vits_style_text').val(this.settings.style_text);
$('#vits_style_weight').val(this.settings.style_weight);
// Update the UI to reflect changes
$('#vits_length_output').text(this.settings.length);
$('#vits_noise_output').text(this.settings.noise);
$('#vits_noisew_output').text(this.settings.noisew);
$('#vits_segment_size_output').text(this.settings.segment_size);
$('#vits_sdp_ratio_output').text(this.settings.sdp_ratio);
$('#vits_emotion_output').text(this.settings.emotion);
$('#vits_style_weight_output').text(this.settings.style_weight);
// Register input/change event listeners to update settings on user interaction
$('#vits_endpoint').on('input', () => { this.onSettingsChange(); });
$('#vits_lang').on('change', () => { this.onSettingsChange(); });
$('#vits_format').on('change', () => { this.onSettingsChange(); });
$('#vits_length').on('change', () => { this.onSettingsChange(); });
$('#vits_noise').on('change', () => { this.onSettingsChange(); });
$('#vits_noisew').on('change', () => { this.onSettingsChange(); });
$('#vits_segment_size').on('change', () => { this.onSettingsChange(); });
$('#vits_streaming').on('change', () => { this.onSettingsChange(); });
$('#vits_dim_emotion').on('change', () => { this.onSettingsChange(); });
$('#vits_sdp_ratio').on('change', () => { this.onSettingsChange(); });
$('#vits_emotion').on('change', () => { this.onSettingsChange(); });
$('#vits_text_prompt').on('change', () => { this.onSettingsChange(); });
$('#vits_style_text').on('change', () => { this.onSettingsChange(); });
$('#vits_style_weight').on('change', () => { this.onSettingsChange(); });
await this.checkReady();
console.info('VITS: Settings loaded');
}
// Perform a simple readiness check by trying to fetch voiceIds
async checkReady() {
await Promise.allSettled([this.fetchTtsVoiceObjects(), this.changeTTSSettings()]);
}
async onRefreshClick() {
return;
}
//#################//
// TTS Interfaces //
//#################//
async getVoice(voiceName) {
if (this.voices.length == 0) {
this.voices = await this.fetchTtsVoiceObjects();
}
const match = this.voices.filter(
v => v.name == voiceName,
)[0];
if (!match) {
throw `TTS Voice name ${voiceName} not found`;
}
return match;
}
async getVoiceById(voiceId) {
if (this.voices.length == 0) {
this.voices = await this.fetchTtsVoiceObjects();
}
const match = this.voices.filter(
v => v.voice_id == voiceId,
)[0];
if (!match) {
throw `TTS Voice id ${voiceId} not found`;
}
return match;
}
async generateTts(text, voiceId) {
const response = await this.fetchTtsGeneration(text, voiceId);
return response;
}
//###########//
// API CALLS //
//###########//
async fetchTtsVoiceObjects() {
const response = await fetch(`${this.settings.provider_endpoint}/voice/speakers`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`);
}
const jsonData = await response.json();
const voices = [];
const addVoices = (modelType) => {
jsonData[modelType].forEach(voice => {
voices.push({
name: `[${modelType}] ${voice.name} (${voice.lang})`,
voice_id: `${modelType}&${voice.id}`,
preview_url: false,
lang: voice.lang,
});
});
};
for (const key in this.modelTypes) {
addVoices(this.modelTypes[key]);
}
this.voices = voices; // Assign to the class property
return voices; // Also return this list
}
// Each time a parameter is changed, we change the configuration
async changeTTSSettings() {
}
/**
* Fetch TTS generation from the API.
* @param {string} inputText Text to generate TTS for
* @param {string} voiceId Voice ID to use (model_type&speaker_id))
* @returns {Promise<Response|string>} Fetch response
*/
async fetchTtsGeneration(inputText, voiceId, lang = null, forceNoStreaming = false) {
console.info(`Generating new TTS for voice_id ${voiceId}`);
const streaming = !forceNoStreaming && this.settings.streaming;
const [model_type, speaker_id] = voiceId.split('&');
const params = new URLSearchParams();
params.append('text', inputText);
params.append('id', speaker_id);
if (streaming) {
params.append('streaming', streaming);
// Streaming response only supports MP3
}
else {
params.append('format', this.settings.format);
}
params.append('lang', lang ?? this.settings.lang);
params.append('length', this.settings.length);
params.append('noise', this.settings.noise);
params.append('noisew', this.settings.noisew);
params.append('segment_size', this.settings.segment_size);
if (model_type == this.modelTypes.W2V2_VITS) {
params.append('emotion', this.settings.dim_emotion);
}
else if (model_type == this.modelTypes.BERT_VITS2) {
params.append('sdp_ratio', this.settings.sdp_ratio);
params.append('emotion', this.settings.emotion);
if (this.settings.text_prompt) {
params.append('text_prompt', this.settings.text_prompt);
}
if (this.settings.style_text) {
params.append('style_text', this.settings.style_text);
params.append('style_weight', this.settings.style_weight);
}
}
const url = `${this.settings.provider_endpoint}/voice/${model_type.toLowerCase()}`;
if (streaming) {
return url + `?${params.toString()}`;
}
const response = await fetch(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
},
);
if (!response.ok) {
toastr.error(response.statusText, 'TTS Generation Failed');
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response;
}
/**
* Preview TTS for a given voice ID.
* @param {string} id Voice ID
*/
async previewTtsVoice(id) {
this.audioElement.pause();
this.audioElement.currentTime = 0;
const voice = await this.getVoiceById(id);
const lang = voice.lang.includes(this.settings.lang) ? this.settings.lang : voice.lang[0];
let lang_code = this.langKey2LangCode[lang];
const text = getPreviewString(lang_code);
const response = await this.fetchTtsGeneration(text, id, lang, true);
if (typeof response != 'string') {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const audio = await response.blob();
const url = URL.createObjectURL(audio);
this.audioElement.src = url;
this.audioElement.play();
}
}
// Interface not used
async fetchTtsFromHistory(history_item_id) {
return Promise.resolve(history_item_id);
}
}

View File

@@ -70,6 +70,7 @@ import {
animation_duration,
depth_prompt_role_default,
shouldAutoContinue,
this_chid,
} from '../script.js';
import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';
import { FILTER_TYPES, FilterHelper } from './filters.js';
@@ -120,6 +121,8 @@ const DEFAULT_AUTO_MODE_DELAY = 5;
export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick));
let autoModeWorker = null;
const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed);
/** @type {Map<string, number>} */
let groupChatQueueOrder = new Map();
function setAutoModeWorker() {
clearInterval(autoModeWorker);
@@ -178,8 +181,37 @@ async function loadGroupChat(chatId) {
return [];
}
async function validateGroup(group) {
if (!group) return;
// Validate that all members exist as characters
let dirty = false;
group.members = group.members.filter(member => {
const character = characters.find(x => x.avatar === member || x.name === member);
if (!character) {
const msg = `Warning: Listed member ${member} does not exist as a character. It will be removed from the group.`;
toastr.warning(msg, 'Group Validation');
console.warn(msg);
dirty = true;
}
return character;
});
if (dirty) {
await editGroup(group.id, true, false);
}
}
export async function getGroupChat(groupId, reload = false) {
const group = groups.find((x) => x.id === groupId);
if (!group) {
console.warn('Group not found', groupId);
return;
}
// Run validation before any loading
validateGroup(group);
const chat_id = group.chat_id;
const data = await loadGroupChat(chat_id);
let freshChat = false;
@@ -197,7 +229,6 @@ export async function getGroupChat(groupId, reload = false) {
if (group && Array.isArray(group.members)) {
for (let member of group.members) {
const character = characters.find(x => x.avatar === member || x.name === member);
if (!character) {
continue;
}
@@ -219,10 +250,8 @@ export async function getGroupChat(groupId, reload = false) {
freshChat = true;
}
if (group) {
let metadata = group.chat_metadata ?? {};
updateChatMetadata(metadata, true);
}
let metadata = group.chat_metadata ?? {};
updateChatMetadata(metadata, true);
if (reload) {
select_group_chats(groupId, true);
@@ -492,7 +521,13 @@ async function saveGroupChat(groupId, shouldSaveGroup) {
body: JSON.stringify({ id: chat_id, chat: [...chat] }),
});
if (shouldSaveGroup && response.ok) {
if (!response.ok) {
toastr.error('Check the server connection and reload the page to prevent data loss.', 'Group Chat could not be saved');
console.error('Group chat could not be saved', response);
return;
}
if (shouldSaveGroup) {
await editGroup(groupId, false, false);
}
}
@@ -546,9 +581,11 @@ export async function renameGroupMember(oldAvatar, newAvatar, newName) {
body: JSON.stringify({ id: chatId, chat: [...messages] }),
});
if (saveChatResponse.ok) {
console.log(`Renamed character ${newName} in group chat: ${chatId}`);
if (!saveChatResponse.ok) {
throw new Error('Group member could not be renamed');
}
console.log(`Renamed character ${newName} in group chat: ${chatId}`);
}
}
}
@@ -821,9 +858,15 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
const bias = getBiasStrings(userInput, type);
await sendMessageAsUser(userInput, bias.messageBias);
await saveChatConditional();
$('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles:true }));
$('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true }));
}
groupChatQueueOrder = new Map();
if (power_user.show_group_chat_queue) {
for (let i = 0; i < activatedMembers.length; ++i) {
groupChatQueueOrder.set(characters[activatedMembers[i]].avatar, i + 1);
}
}
// now the real generation begins: cycle through every activated character
for (const chId of activatedMembers) {
throwIfAborted();
@@ -831,6 +874,9 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
const generateType = type == 'swipe' || type == 'impersonate' || type == 'quiet' || type == 'continue' ? type : 'group_chat';
setCharacterId(chId);
setCharacterName(characters[chId].name);
if (power_user.show_group_chat_queue) {
printGroupMembers();
}
await eventSource.emit(event_types.GROUP_MEMBER_DRAFTED, chId);
if (type !== 'swipe' && type !== 'impersonate' && !isStreamingEnabled()) {
@@ -851,6 +897,10 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
messageChunk = textResult?.messageChunk;
}
}
if (power_user.show_group_chat_queue) {
groupChatQueueOrder.delete(characters[chId].avatar);
groupChatQueueOrder.forEach((value, key, map) => map.set(key, value - 1));
}
}
} finally {
typingIndicator.hide();
@@ -858,6 +908,10 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
is_group_generating = false;
setSendButtonState(false);
setCharacterId(undefined);
if (power_user.show_group_chat_queue) {
groupChatQueueOrder = new Map();
printGroupMembers();
}
setCharacterName('');
activateSendButtons();
showSwipeButtons();
@@ -972,6 +1026,7 @@ function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, i
}
}
const chattyMembers = [];
// activation by talkativeness (in shuffled order, except banned)
const shuffledMembers = shuffle([...members]);
for (let member of shuffledMembers) {
@@ -982,26 +1037,30 @@ function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, i
}
const rollValue = Math.random();
let talkativeness = Number(character.talkativeness);
talkativeness = Number.isNaN(talkativeness)
const talkativeness = isNaN(character.talkativeness)
? talkativeness_default
: talkativeness;
: Number(character.talkativeness);
if (talkativeness >= rollValue) {
activatedMembers.push(member);
}
if (talkativeness > 0) {
chattyMembers.push(member);
}
}
// pick 1 at random if no one was activated
let retries = 0;
while (activatedMembers.length === 0 && ++retries <= members.length) {
const randomIndex = Math.floor(Math.random() * members.length);
const character = characters.find((x) => x.avatar === members[randomIndex]);
// try to limit the selected random character to those with talkativeness > 0
const randomPool = chattyMembers.length > 0 ? chattyMembers : members;
while (activatedMembers.length === 0 && ++retries <= randomPool.length) {
const randomIndex = Math.floor(Math.random() * randomPool.length);
const character = characters.find((x) => x.avatar === randomPool[randomIndex]);
if (!character) {
continue;
}
activatedMembers.push(members[randomIndex]);
activatedMembers.push(randomPool[randomIndex]);
}
// de-duplicate array of character avatars
@@ -1279,6 +1338,14 @@ function getGroupCharacterBlock(character) {
template.attr('chid', characters.indexOf(character));
template.find('.ch_fav').val(isFav);
template.toggleClass('is_fav', isFav);
let queuePosition = groupChatQueueOrder.get(character.avatar);
if (queuePosition) {
template.find('.queue_position').text(queuePosition);
template.toggleClass('is_queued', queuePosition > 1);
template.toggleClass('is_active', queuePosition === 1);
}
template.toggleClass('disabled', isGroupMemberDisabled(character.avatar));
// Display inline tags
@@ -1568,7 +1635,10 @@ export async function openGroupById(groupId) {
}
if (!is_send_press && !is_group_generating) {
select_group_chats(groupId);
if (selected_group !== groupId) {
groupChatQueueOrder = new Map();
await clearChat();
cancelTtsPlay();
selected_group = groupId;
@@ -1579,8 +1649,6 @@ export async function openGroupById(groupId) {
chat.length = 0;
await getGroupChat(groupId);
}
select_group_chats(groupId);
}
}
@@ -1828,11 +1896,16 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
await editGroup(groupId, true, false);
await fetch('/api/chats/group/save', {
const response = await fetch('/api/chats/group/save', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ id: name, chat: [...trimmed_chat] }),
});
if (!response.ok) {
toastr.error('Check the server connection and reload the page to prevent data loss.', 'Group chat could not be saved');
console.error('Group chat could not be saved', response);
}
}
function onSendTextareaInput() {

View File

@@ -9,6 +9,71 @@ const langs = await fetch('/locales/lang.json').then(response => response.json()
// eslint-disable-next-line prefer-const
var localeData = await getLocaleData(localeFile);
/**
* An observer that will check if any new i18n elements are added to the document
* @type {MutationObserver}
*/
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element) {
if (node.hasAttribute('data-i18n')) {
translateElement(node);
}
node.querySelectorAll('[data-i18n]').forEach(element => {
translateElement(element);
});
}
});
if (mutation.attributeName === 'data-i18n' && mutation.target instanceof Element) {
translateElement(mutation.target);
}
});
});
/**
* Translates a template string with named arguments
*
* Uses the template literal with all values replaced by index placeholder for translation key.
*
* @example
* ```js
* toastr.warn(t`Tag ${tagName} not found.`);
* ```
* Should be translated in the translation files as:
* ```
* Tag ${0} not found. -> Tag ${0} nicht gefunden.
* ```
*
* @param {TemplateStringsArray} strings - Template strings array
* @param {...any} values - Values for placeholders in the template string
* @returns {string} Translated and formatted string
*/
export function t(strings, ...values) {
let str = strings.reduce((result, string, i) => result + string + (values[i] !== undefined ? `\${${i}}` : ''), '');
let translatedStr = translate(str);
// Replace indexed placeholders with actual values
return translatedStr.replace(/\$\{(\d+)\}/g, (match, index) => values[index]);
}
/**
* Translates a given key or text
*
* If the translation is based on a key, that one is used to find a possible translation in the translation file.
* The original text still has to be provided, as that is the default value being returned if no translation is found.
*
* For in-code text translation on a format string, using the template literal `t` is preferred.
*
* @param {string} text - The text to translate
* @param {string?} key - The key to use for translation. If not provided, text is used as the key.
* @returns {string} - The translated text
*/
export function translate(text, key = null) {
const translationKey = key || text;
return localeData?.[translationKey] || text;
}
/**
* Fetches the locale data for the given language.
* @param {string} language Language code
@@ -40,6 +105,29 @@ function findLang(language) {
return supportedLang;
}
/**
* Translates a given element based on its data-i18n attribute.
* @param {Element} element The element to translate
*/
function translateElement(element) {
const keys = element.getAttribute('data-i18n').split(';'); // Multi-key entries are ; delimited
for (const key of keys) {
const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key
if (attributeMatch) { // attribute-tagged key
const localizedValue = localeData?.[attributeMatch[2]];
if (localizedValue || localizedValue === '') {
element.setAttribute(attributeMatch[1], localizedValue);
}
} else { // No attribute tag, treat as 'text'
const localizedValue = localeData?.[key];
if (localizedValue || localizedValue === '') {
element.textContent = localizedValue;
}
}
}
}
async function getMissingTranslations() {
const missingData = [];
@@ -103,22 +191,7 @@ export function applyLocale(root = document) {
//find all the elements with `data-i18n` attribute
$root.find('[data-i18n]').each(function () {
//read the translation from the language data
const keys = $(this).data('i18n').split(';'); // Multi-key entries are ; delimited
for (const key of keys) {
const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key
if (attributeMatch) { // attribute-tagged key
const localizedValue = localeData?.[attributeMatch[2]];
if (localizedValue || localizedValue == '') {
$(this).attr(attributeMatch[1], localizedValue);
}
} else { // No attribute tag, treat as 'text'
const localizedValue = localeData?.[key];
if (localizedValue || localizedValue == '') {
$(this).text(localizedValue);
}
}
}
translateElement(this);
});
if (root !== document) {
@@ -126,7 +199,6 @@ export function applyLocale(root = document) {
}
}
function addLanguagesToDropdown() {
const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select');
for (const langObj of langs) { // Set the value to the language code
@@ -159,6 +231,13 @@ export function initLocales() {
location.reload();
});
observer.observe(document, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-i18n'],
});
registerDebugFunction('getMissingTranslations', 'Get missing translations', 'Detects missing localization data in the current locale and dumps the data into the browser console. If the current locale is English, searches all other locales.', getMissingTranslations);
registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale);
}

View File

@@ -1,25 +1,55 @@
const ELEMENT_ID = 'loader';
import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
/** @type {Popup} */
let loaderPopup;
let preloaderYoinked = false;
export function showLoader() {
const container = $('<div></div>').attr('id', ELEMENT_ID);
const loader = $('<div></div>').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x');
container.append(loader);
$('body').append(container);
// Two loaders don't make sense. Don't await, we can overlay the old loader while it closes
if (loaderPopup) loaderPopup.complete(POPUP_RESULT.CANCELLED);
loaderPopup = new Popup(`
<div id="loader">
<div id="load-spinner" class="fa-solid fa-gear fa-spin fa-3x"></div>
</div>`, POPUP_TYPE.DISPLAY, null, { transparent: true, animation: 'none' });
// No close button, loaders are not closable
loaderPopup.closeButton.style.display = 'none';
loaderPopup.show();
}
export async function hideLoader() {
//Sets up a 2-step animation. Spinner blurs/fades out, and then the loader shadow does the same.
$('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {
$(`#${ELEMENT_ID}`)
//only fade out the spinner and replace with login screen
.animate({ opacity: 0 }, 300, function () {
$(`#${ELEMENT_ID}`).remove();
if (!loaderPopup) {
console.warn('There is no loader showing to hide');
return Promise.resolve();
}
return new Promise((resolve) => {
// Spinner blurs/fades out
$('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {
$('#loader').remove();
// Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
// If it's present, we remove it once and then it's gone.
yoinkPreloader();
loaderPopup.complete(POPUP_RESULT.AFFIRMATIVE).then(() => {
loaderPopup = null;
resolve();
});
});
$('#load-spinner')
.css({
'filter': 'blur(15px)',
'opacity': '0',
});
});
$('#load-spinner')
.css({
'filter': 'blur(15px)',
'opacity': '0',
});
}
function yoinkPreloader() {
if (preloaderYoinked) return;
document.getElementById('preloader').remove();
preloaderYoinked = true;
}

View File

@@ -1,5 +1,5 @@
import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId, substituteParams } from '../script.js';
import { timestampToMoment, isDigitsOnly, getStringHash } from './utils.js';
import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } from './utils.js';
import { textgenerationwebui_banned_in_macros } from './textgen-settings.js';
import { replaceInstructMacros } from './instruct-mode.js';
import { replaceVariableMacros } from './variables.js';
@@ -13,6 +13,132 @@ Handlebars.registerHelper('helperMissing', function () {
return substituteParams(`{{${macroName}}}`);
});
/**
* @typedef {Object<string, *>} EnvObject
* @typedef {(nonce: string) => string} MacroFunction
*/
export class MacrosParser {
/**
* A map of registered macros.
* @type {Map<string, string|MacroFunction>}
*/
static #macros = new Map();
/**
* Registers a global macro that can be used anywhere where substitution is allowed.
* @param {string} key Macro name (key)
* @param {string|MacroFunction} value A string or a function that returns a string
*/
static registerMacro(key, value) {
if (typeof key !== 'string') {
throw new Error('Macro key must be a string');
}
// Allowing surrounding whitespace would just create more confusion...
key = key.trim();
if (!key) {
throw new Error('Macro key must not be empty or whitespace only');
}
if (key.startsWith('{{') || key.endsWith('}}')) {
throw new Error('Macro key must not include the surrounding braces');
}
if (typeof value !== 'string' && typeof value !== 'function') {
console.warn(`Macro value for "${key}" will be converted to a string`);
value = this.sanitizeMacroValue(value);
}
if (this.#macros.has(key)) {
console.warn(`Macro ${key} is already registered`);
}
this.#macros.set(key, value);
}
/**
* Unregisters a global macro with the given key
*
* @param {string} key Macro name (key)
*/
static unregisterMacro(key) {
if (typeof key !== 'string') {
throw new Error('Macro key must be a string');
}
// Allowing surrounding whitespace would just create more confusion...
key = key.trim();
if (!key) {
throw new Error('Macro key must not be empty or whitespace only');
}
const deleted = this.#macros.delete(key);
if (!deleted) {
console.warn(`Macro ${key} was not registered`);
}
}
/**
* Populate the env object with macro values from the current context.
* @param {EnvObject} env Env object for the current evaluation context
* @returns {void}
*/
static populateEnv(env) {
if (!env || typeof env !== 'object') {
console.warn('Env object is not provided');
return;
}
// No macros are registered
if (this.#macros.size === 0) {
return;
}
for (const [key, value] of this.#macros) {
env[key] = value;
}
}
/**
* Performs a type-check on the macro value and returns a sanitized version of it.
* @param {any} value Value returned by a macro
* @returns {string} Sanitized value
*/
static sanitizeMacroValue(value) {
if (typeof value === 'string') {
return value;
}
if (value === null || value === undefined) {
return '';
}
if (value instanceof Promise) {
console.warn('Promises are not supported as macro values');
return '';
}
if (typeof value === 'function') {
console.warn('Functions are not supported as macro values');
return '';
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
}
/**
* Gets a hashed id of the current chat from the metadata.
* If no metadata exists, creates a new hash and saves it.
@@ -128,7 +254,7 @@ function getCurrentSwipeId() {
// For swipe macro, we are accepting using the message that is currently being swiped
const mid = getLastMessageId({ exclude_swipe_in_propress: false });
const swipeId = chat[mid]?.swipe_id;
return swipeId ? swipeId + 1 : null;
return swipeId !== null ? swipeId + 1 : null;
}
/**
@@ -275,7 +401,7 @@ function timeDiffReplace(input) {
const time2 = moment(matchPart2);
const timeDifference = moment.duration(time1.diff(time2));
return timeDifference.humanize();
return timeDifference.humanize(true);
});
return output;
@@ -284,7 +410,7 @@ function timeDiffReplace(input) {
/**
* Substitutes {{macro}} parameters in a string.
* @param {string} content - The string to substitute parameters in.
* @param {Object<string, *>} env - Map of macro names to the values they'll be substituted with. If the param
* @param {EnvObject} env - Map of macro names to the values they'll be substituted with. If the param
* values are functions, those functions will be called and their return values are used.
* @returns {string} The string with substituted parameters.
*/
@@ -311,16 +437,23 @@ export function evaluateMacros(content, env) {
content = replaceInstructMacros(content, env);
content = replaceVariableMacros(content);
content = content.replace(/{{newline}}/gi, '\n');
content = content.replace(/\n*{{trim}}\n*/gi, '');
content = content.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, '');
content = content.replace(/{{noop}}/gi, '');
content = content.replace(/{{input}}/gi, () => String($('#send_textarea').val()));
// Add all registered macros to the env object
const nonce = uuidv4();
MacrosParser.populateEnv(env);
// Substitute passed-in variables
for (const varName in env) {
if (!Object.hasOwn(env, varName)) continue;
const param = env[varName];
content = content.replace(new RegExp(`{{${varName}}}`, 'gi'), param);
content = content.replace(new RegExp(`{{${escapeRegex(varName)}}}`, 'gi'), () => {
const param = env[varName];
const value = MacrosParser.sanitizeMacroValue(typeof param === 'function' ? param(nonce) : param);
return value;
});
}
content = content.replace(/{{maxPrompt}}/gi, () => String(getMaxContextSize()));

View File

@@ -181,6 +181,7 @@ export const chat_completion_sources = {
COHERE: 'cohere',
PERPLEXITY: 'perplexity',
GROQ: 'groq',
ZEROONEAI: '01ai',
};
const character_names_behavior = {
@@ -251,6 +252,7 @@ const default_settings = {
cohere_model: 'command-r',
perplexity_model: 'llama-3-70b-instruct',
groq_model: 'llama3-70b-8192',
zerooneai_model: 'yi-large',
custom_model: '',
custom_url: '',
custom_include_body: '',
@@ -329,6 +331,7 @@ const oai_settings = {
cohere_model: 'command-r',
perplexity_model: 'llama-3-70b-instruct',
groq_model: 'llama3-70b-8192',
zerooneai_model: 'yi-large',
custom_model: '',
custom_url: '',
custom_include_body: '',
@@ -686,7 +689,7 @@ function formatWorldInfo(value) {
return '';
}
if (!oai_settings.wi_format) {
if (!oai_settings.wi_format.trim()) {
return value;
}
@@ -1470,6 +1473,8 @@ function getChatCompletionModel() {
return oai_settings.perplexity_model;
case chat_completion_sources.GROQ:
return oai_settings.groq_model;
case chat_completion_sources.ZEROONEAI:
return oai_settings.zerooneai_model;
default:
throw new Error(`Unknown chat completion source: ${oai_settings.chat_completion_source}`);
}
@@ -1566,6 +1571,23 @@ function saveModelList(data) {
$('#model_custom_select').val(model_list[0].id).trigger('change');
}
}
if (oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI) {
$('#model_01ai_select').empty();
model_list.forEach((model) => {
$('#model_01ai_select').append(
$('<option>', {
value: model.id,
text: model.id,
}));
});
if (!oai_settings.zerooneai_model && model_list.length > 0) {
oai_settings.zerooneai_model = model_list[0].id;
}
$('#model_01ai_select').val(oai_settings.zerooneai_model).trigger('change');
}
}
function appendOpenRouterOptions(model_list, groupModels = false, sort = false) {
@@ -1697,6 +1719,7 @@ async function sendOpenAIRequest(type, messages, signal) {
const isCohere = oai_settings.chat_completion_source == chat_completion_sources.COHERE;
const isPerplexity = oai_settings.chat_completion_source == chat_completion_sources.PERPLEXITY;
const isGroq = oai_settings.chat_completion_source == chat_completion_sources.GROQ;
const is01AI = oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI;
const isTextCompletion = (isOAI && textCompletionModels.includes(oai_settings.openai_model)) || (isOpenRouter && oai_settings.openrouter_force_instruct && power_user.instruct.enabled);
const isQuiet = type === 'quiet';
const isImpersonate = type === 'impersonate';
@@ -1863,6 +1886,17 @@ async function sendOpenAIRequest(type, messages, signal) {
delete generate_data.n;
}
// https://platform.01.ai/docs#request-body
if (is01AI) {
delete generate_data.logprobs;
delete generate_data.logit_bias;
delete generate_data.top_logprobs;
delete generate_data.n;
delete generate_data.frequency_penalty;
delete generate_data.presence_penalty;
delete generate_data.stop;
}
if ((isOAI || isOpenRouter || isMistral || isCustom || isCohere) && oai_settings.seed >= 0) {
generate_data['seed'] = oai_settings.seed;
}
@@ -2912,6 +2946,7 @@ function loadOpenAISettings(data, settings) {
oai_settings.cohere_model = settings.cohere_model ?? default_settings.cohere_model;
oai_settings.perplexity_model = settings.perplexity_model ?? default_settings.perplexity_model;
oai_settings.groq_model = settings.groq_model ?? default_settings.groq_model;
oai_settings.zerooneai_model = settings.zerooneai_model ?? default_settings.zerooneai_model;
oai_settings.custom_model = settings.custom_model ?? default_settings.custom_model;
oai_settings.custom_url = settings.custom_url ?? default_settings.custom_url;
oai_settings.custom_include_body = settings.custom_include_body ?? default_settings.custom_include_body;
@@ -2988,6 +3023,7 @@ function loadOpenAISettings(data, settings) {
$(`#model_perplexity_select option[value="${oai_settings.perplexity_model}"`).attr('selected', true);
$('#model_groq_select').val(oai_settings.groq_model);
$(`#model_groq_select option[value="${oai_settings.groq_model}"`).attr('selected', true);
$('#model_01ai_select').val(oai_settings.zerooneai_model);
$('#custom_model_id').val(oai_settings.custom_model);
$('#custom_api_url_text').val(oai_settings.custom_url);
$('#openai_max_context').val(oai_settings.openai_max_context);
@@ -3240,6 +3276,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
cohere_model: settings.cohere_model,
perplexity_model: settings.perplexity_model,
groq_model: settings.groq_model,
zerooneai_model: settings.zerooneai_model,
custom_model: settings.custom_model,
custom_url: settings.custom_url,
custom_include_body: settings.custom_include_body,
@@ -3328,6 +3365,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
}
} else {
toastr.error('Failed to save preset');
throw new Error('Failed to save preset');
}
}
@@ -3640,6 +3678,7 @@ function onSettingsPresetChange() {
cohere_model: ['#model_cohere_select', 'cohere_model', false],
perplexity_model: ['#model_perplexity_select', 'perplexity_model', false],
groq_model: ['#model_groq_select', 'groq_model', false],
zerooneai_model: ['#model_01ai_select', 'zerooneai_model', false],
custom_model: ['#custom_model_id', 'custom_model', false],
custom_url: ['#custom_api_url_text', 'custom_url', false],
custom_include_body: ['#custom_include_body', 'custom_include_body', false],
@@ -3882,6 +3921,11 @@ async function onModelChange() {
oai_settings.groq_model = value;
}
if ($(this).is('#model_01ai_select')) {
console.log('01.AI model changed to', value);
oai_settings.zerooneai_model = value;
}
if (value && $(this).is('#model_custom_select')) {
console.log('Custom model changed to', value);
oai_settings.custom_model = value;
@@ -3997,7 +4041,9 @@ async function onModelChange() {
}
if (oai_settings.chat_completion_source === chat_completion_sources.MISTRALAI) {
if (oai_settings.mistralai_model.includes('mixtral-8x22b')) {
if (oai_settings.max_context_unlocked) {
$('#openai_max_context').attr('max', unlocked_max);
} else if (oai_settings.mistralai_model.includes('mixtral-8x22b')) {
$('#openai_max_context').attr('max', max_64k);
} else {
$('#openai_max_context').attr('max', max_32k);
@@ -4120,6 +4166,20 @@ async function onModelChange() {
$('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
}
if (oai_settings.chat_completion_source === chat_completion_sources.ZEROONEAI) {
if (oai_settings.max_context_unlocked) {
$('#openai_max_context').attr('max', unlocked_max);
} else {
$('#openai_max_context').attr('max', max_16k);
}
oai_settings.openai_max_context = Math.min(oai_settings.openai_max_context, Number($('#openai_max_context').attr('max')));
$('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
$('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
}
$('#openai_max_context_counter').attr('max', Number($('#openai_max_context').attr('max')));
saveSettingsDebounced();
@@ -4314,6 +4374,19 @@ async function onConnectButtonClick(e) {
}
}
if (oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI) {
const api_key_01ai = String($('#api_key_01ai').val()).trim();
if (api_key_01ai.length) {
await writeSecret(SECRET_KEYS.ZEROONEAI, api_key_01ai);
}
if (!secret_state[SECRET_KEYS.ZEROONEAI]) {
console.log('No secret key saved for 01.AI');
return;
}
}
startStatusLoading();
saveSettingsDebounced();
await getStatusOpen();
@@ -4358,6 +4431,9 @@ function toggleChatCompletionForms() {
else if (oai_settings.chat_completion_source == chat_completion_sources.GROQ) {
$('#model_groq_select').trigger('change');
}
else if (oai_settings.chat_completion_source == chat_completion_sources.ZEROONEAI) {
$('#model_01ai_select').trigger('change');
}
else if (oai_settings.chat_completion_source == chat_completion_sources.CUSTOM) {
$('#model_custom_select').trigger('change');
}
@@ -5062,6 +5138,7 @@ $(document).ready(async function () {
$('#model_cohere_select').on('change', onModelChange);
$('#model_perplexity_select').on('change', onModelChange);
$('#model_groq_select').on('change', onModelChange);
$('#model_01ai_select').on('change', onModelChange);
$('#model_custom_select').on('change', onModelChange);
$('#settings_preset_openai').on('change', onSettingsPresetChange);
$('#new_oai_preset').on('click', onNewPresetClick);

View File

@@ -590,7 +590,37 @@ function selectCurrentPersona() {
}
}
async function lockUserNameToChat() {
/**
* Checks if the persona is locked for the current chat.
* @returns {boolean} Whether the persona is locked
*/
function isPersonaLocked() {
return !!chat_metadata['persona'];
}
/**
* Locks or unlocks the persona for the current chat.
* @param {boolean} state Desired lock state
* @returns {Promise<void>}
*/
export async function setPersonaLockState(state) {
return state ? await lockPersona() : await unlockPersona();
}
/**
* Toggle the persona lock state for the current chat.
* @returns {Promise<void>}
*/
export async function togglePersonaLock() {
return isPersonaLocked()
? await unlockPersona()
: await lockPersona();
}
/**
* Unlock the persona for the current chat.
*/
async function unlockPersona() {
if (chat_metadata['persona']) {
console.log(`Unlocking persona for this chat ${chat_metadata['persona']}`);
delete chat_metadata['persona'];
@@ -599,9 +629,13 @@ async function lockUserNameToChat() {
toastr.info('User persona is now unlocked for this chat. Click the "Lock" again to revert.', 'Persona unlocked');
}
updateUserLockIcon();
return;
}
}
/**
* Lock the persona for the current chat.
*/
async function lockPersona() {
if (!(user_avatar in power_user.personas)) {
console.log(`Creating a new persona ${user_avatar}`);
if (power_user.persona_show_notifications) {
@@ -625,6 +659,7 @@ async function lockUserNameToChat() {
updateUserLockIcon();
}
async function deleteUserAvatar(e) {
e?.stopPropagation();
const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
@@ -973,7 +1008,7 @@ export function initPersonas() {
$(document).on('click', '.bind_user_name', bindUserNameToPersona);
$(document).on('click', '.set_default_persona', setDefaultPersona);
$(document).on('click', '.delete_avatar', deleteUserAvatar);
$('#lock_user_name').on('click', lockUserNameToChat);
$('#lock_user_name').on('click', togglePersonaLock);
$('#create_dummy_persona').on('click', createDummyPersona);
$('#persona_description').on('input', onPersonaDescriptionInput);
$('#persona_description_position').on('input', onPersonaDescriptionPositionInput);

View File

@@ -1,3 +1,4 @@
import { shouldSendOnEnter } from './RossAscends-mods.js';
import { power_user } from './power-user.js';
import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';
@@ -35,8 +36,10 @@ export const POPUP_RESULT = {
* @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content)
* @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup
* @property {boolean?} [allowVerticalScrolling=false] - Whether to allow vertical scrolling in the popup
* @property {'slow'|'fast'|'none'?} [animation='slow'] - Animation speed for the popup (opening, closing, ...)
* @property {POPUP_RESULT|number?} [defaultResult=POPUP_RESULT.AFFIRMATIVE] - The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`.
* @property {CustomPopupButton[]|string[]?} [customButtons=null] - Custom buttons to add to the popup. If only strings are provided, the buttons will be added with default options, and their result will be in order from `2` onward.
* @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.
* @property {(popup: Popup) => boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close
* @property {(popup: Popup) => void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up
* @property {number?} [cropAspect=null] - Aspect ratio for the crop popup
@@ -52,6 +55,14 @@ export const POPUP_RESULT = {
* @property {boolean?} [appendAtEnd] - Whether to append the button to the end of the popup - by default it will be prepended
*/
/**
* @typedef {object} CustomPopupInput
* @property {string} id - The id for the html element
* @property {string} label - The label text for the input
* @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label
* @property {boolean?} [defaultState=false] - The default state when opening the popup (false if not set)
*/
/**
* @typedef {object} ShowPopupHelper
* Local implementation of the helper functionality to show several popups.
@@ -78,8 +89,8 @@ const showPopupHelper = {
/**
* Asynchronously displays a confirmation popup with the given header and text, returning the clicked result button value.
*
* @param {string} header - The header text for the popup.
* @param {string} text - The main text for the popup.
* @param {string?} header - The header text for the popup.
* @param {string?} text - The main text for the popup.
* @param {PopupOptions} [popupOptions={}] - Options for the popup.
* @return {Promise<POPUP_RESULT>} A Promise that resolves with the result of the user's interaction.
*/
@@ -89,38 +100,41 @@ const showPopupHelper = {
const result = await popup.show();
if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);
return result;
}
},
};
export class Popup {
/** @type {POPUP_TYPE} */ type;
/** @readonly @type {POPUP_TYPE} */ type;
/** @type {string} */ id;
/** @readonly @type {string} */ id;
/** @type {HTMLDialogElement} */ dlg;
/** @type {HTMLElement} */ body;
/** @type {HTMLElement} */ content;
/** @type {HTMLTextAreaElement} */ input;
/** @type {HTMLElement} */ controls;
/** @type {HTMLElement} */ okButton;
/** @type {HTMLElement} */ cancelButton;
/** @type {HTMLElement} */ closeButton;
/** @type {HTMLElement} */ cropWrap;
/** @type {HTMLImageElement} */ cropImage;
/** @type {POPUP_RESULT|number?} */ defaultResult;
/** @type {CustomPopupButton[]|string[]?} */ customButtons;
/** @readonly @type {HTMLDialogElement} */ dlg;
/** @readonly @type {HTMLDivElement} */ body;
/** @readonly @type {HTMLDivElement} */ content;
/** @readonly @type {HTMLTextAreaElement} */ mainInput;
/** @readonly @type {HTMLDivElement} */ inputControls;
/** @readonly @type {HTMLDivElement} */ buttonControls;
/** @readonly @type {HTMLDivElement} */ okButton;
/** @readonly @type {HTMLDivElement} */ cancelButton;
/** @readonly @type {HTMLDivElement} */ closeButton;
/** @readonly @type {HTMLDivElement} */ cropWrap;
/** @readonly @type {HTMLImageElement} */ cropImage;
/** @readonly @type {POPUP_RESULT|number?} */ defaultResult;
/** @readonly @type {CustomPopupButton[]|string[]?} */ customButtons;
/** @readonly @type {CustomPopupInput[]} */ customInputs;
/** @type {(popup: Popup) => boolean?} */ onClosing;
/** @type {(popup: Popup) => void?} */ onClose;
/** @type {POPUP_RESULT|number} */ result;
/** @type {any} */ value;
/** @type {Map<string,boolean>?} */ inputResults;
/** @type {any} */ cropData;
/** @type {HTMLElement} */ lastFocus;
/** @type {Promise<any>} */ promise;
/** @type {(result: any) => any} */ resolver;
/** @type {Promise<any>} */ #promise;
/** @type {(result: any) => any} */ #resolver;
/**
* Constructs a new Popup object with the given text content, type, inputValue, and options
@@ -130,7 +144,7 @@ export class Popup {
* @param {string} [inputValue=''] - The initial value of the input field
* @param {PopupOptions} [options={}] - Additional options for the popup
*/
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {
Popup.util.popups.push(this);
// Make this popup uniquely identifiable
@@ -147,8 +161,9 @@ export class Popup {
this.dlg = template.content.cloneNode(true).querySelector('.popup');
this.body = this.dlg.querySelector('.popup-body');
this.content = this.dlg.querySelector('.popup-content');
this.input = this.dlg.querySelector('.popup-input');
this.controls = this.dlg.querySelector('.popup-controls');
this.mainInput = this.dlg.querySelector('.popup-input');
this.inputControls = this.dlg.querySelector('.popup-inputs');
this.buttonControls = this.dlg.querySelector('.popup-controls');
this.okButton = this.dlg.querySelector('.popup-button-ok');
this.cancelButton = this.dlg.querySelector('.popup-button-cancel');
this.closeButton = this.dlg.querySelector('.popup-button-close');
@@ -162,10 +177,13 @@ export class Popup {
if (transparent) this.dlg.classList.add('transparent_dialogue_popup');
if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup');
if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup');
if (animation) this.dlg.classList.add('popup--animation-' + animation);
// If custom button captions are provided, we set them beforehand
this.okButton.textContent = typeof okButton === 'string' ? okButton : 'OK';
this.okButton.dataset.i18n = this.okButton.textContent;
this.cancelButton.textContent = typeof cancelButton === 'string' ? cancelButton : template.getAttribute('popup-button-cancel');
this.cancelButton.dataset.i18n = this.cancelButton.textContent;
this.defaultResult = defaultResult;
this.customButtons = customButtons;
@@ -178,12 +196,13 @@ export class Popup {
buttonElement.classList.add(...(button.classes ?? []));
buttonElement.dataset.result = String(button.result ?? undefined);
buttonElement.textContent = button.text;
buttonElement.dataset.i18n = buttonElement.textContent;
buttonElement.tabIndex = 0;
if (button.appendAtEnd) {
this.controls.appendChild(buttonElement);
this.buttonControls.appendChild(buttonElement);
} else {
this.controls.insertBefore(buttonElement, this.okButton);
this.buttonControls.insertBefore(buttonElement, this.okButton);
}
if (typeof button.action === 'function') {
@@ -191,13 +210,45 @@ export class Popup {
}
});
this.customInputs = customInputs;
this.customInputs?.forEach(input => {
if (!input.id || !(typeof input.id === 'string')) {
console.warn('Given custom input does not have a valid id set');
return;
}
const label = document.createElement('label');
label.classList.add('checkbox_label', 'justifyCenter');
label.setAttribute('for', input.id);
const inputElement = document.createElement('input');
inputElement.type = 'checkbox';
inputElement.id = input.id;
inputElement.checked = input.defaultState ?? false;
label.appendChild(inputElement);
const labelText = document.createElement('span');
labelText.innerText = input.label;
labelText.dataset.i18n = input.label;
label.appendChild(labelText);
if (input.tooltip) {
const tooltip = document.createElement('div');
tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
tooltip.title = input.tooltip;
tooltip.dataset.i18n = '[title]' + input.tooltip;
label.appendChild(tooltip);
}
this.inputControls.appendChild(label);
});
// Set the default button class
const defaultButton = this.controls.querySelector(`[data-result="${this.defaultResult}"]`);
const defaultButton = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`);
if (defaultButton) defaultButton.classList.add('menu_button_default');
// Styling differences depending on the popup type
// General styling for all types first, that might be overriden for specific types below
this.input.style.display = 'none';
this.mainInput.style.display = 'none';
this.inputControls.style.display = customInputs ? 'block' : 'none';
this.closeButton.style.display = 'none';
this.cropWrap.style.display = 'none';
@@ -212,12 +263,12 @@ export class Popup {
break;
}
case POPUP_TYPE.INPUT: {
this.input.style.display = 'block';
this.mainInput.style.display = 'block';
if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-save');
break;
}
case POPUP_TYPE.DISPLAY: {
this.controls.style.display = 'none';
this.buttonControls.style.display = 'none';
this.closeButton.style.display = 'block';
break;
}
@@ -243,8 +294,8 @@ export class Popup {
}
}
this.input.value = inputValue;
this.input.rows = rows ?? 1;
this.mainInput.value = inputValue;
this.mainInput.rows = rows ?? 1;
this.content.innerHTML = '';
if (content instanceof jQuery) {
@@ -270,20 +321,20 @@ export class Popup {
if (String(undefined) === String(resultControl.dataset.result)) return;
if (isNaN(result)) throw new Error('Invalid result control. Result must be a number. ' + resultControl.dataset.result);
const type = resultControl.dataset.resultEvent || 'click';
resultControl.addEventListener(type, () => this.complete(result));
resultControl.addEventListener(type, async () => await this.complete(result));
});
// Bind dialog listeners manually, so we can be sure context is preserved
const cancelListener = (evt) => {
this.complete(POPUP_RESULT.CANCELLED);
const cancelListener = async (evt) => {
evt.preventDefault();
evt.stopPropagation();
await this.complete(POPUP_RESULT.CANCELLED);
window.removeEventListener('cancel', cancelListenerBound);
};
const cancelListenerBound = cancelListener.bind(this);
this.dlg.addEventListener('cancel', cancelListenerBound);
const keyListener = (evt) => {
const keyListener = async (evt) => {
switch (evt.key) {
case 'Enter': {
// CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this
@@ -294,15 +345,23 @@ export class Popup {
if (this.dlg != document.activeElement?.closest('.popup'))
return;
// Check if the current focus is a result control. Only should we apply the compelete action
// Check if the current focus is a result control. Only should we apply the complete action
const resultControl = document.activeElement?.closest('.result-control');
if (!resultControl)
return;
const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);
this.complete(result);
// Check if we are inside an input type text or a textarea field and send on enter is disabled
const textarea = document.activeElement?.closest('textarea');
if (textarea instanceof HTMLTextAreaElement && !shouldSendOnEnter())
return;
const input = document.activeElement?.closest('input[type="text"]');
if (input instanceof HTMLInputElement && !shouldSendOnEnter())
return;
evt.preventDefault();
evt.stopPropagation();
const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);
await this.complete(result);
window.removeEventListener('keydown', keyListenerBound);
break;
@@ -335,10 +394,10 @@ export class Popup {
this.dlg.removeAttribute('opening');
});
this.promise = new Promise((resolve) => {
this.resolver = resolve;
this.#promise = new Promise((resolve) => {
this.#resolver = resolve;
});
return this.promise;
return this.#promise;
}
setAutoFocus({ applyAutoFocus = false } = {}) {
@@ -352,12 +411,12 @@ export class Popup {
if (!control) {
switch (this.type) {
case POPUP_TYPE.INPUT: {
control = this.input;
control = this.mainInput;
break;
}
default:
// Select default button
control = this.controls.querySelector(`[data-result="${this.defaultResult}"]`);
control = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`);
break;
}
}
@@ -382,14 +441,16 @@ export class Popup {
* - All other will return the result value as provided as `POPUP_RESULT` or a custom number value
*
* @param {POPUP_RESULT|number} result - The result of the popup (either an existing `POPUP_RESULT` or a custom result value)
*
* @returns {Promise<string|number|boolean?>} A promise that resolves with the value of the popup when it is completed.
*/
complete(result) {
async complete(result) {
// In all cases besides INPUT the popup value should be the result
/** @type {POPUP_RESULT|number|boolean|string?} */
let value = result;
// Input type have special results, so the input can be accessed directly without the need to save the popup and access both result and value
if (this.type === POPUP_TYPE.INPUT) {
if (result >= POPUP_RESULT.AFFIRMATIVE) value = this.input.value;
if (result >= POPUP_RESULT.AFFIRMATIVE) value = this.mainInput.value;
else if (result === POPUP_RESULT.NEGATIVE) value = false;
else if (result === POPUP_RESULT.CANCELLED) value = null;
else value = false; // Might a custom negative value?
@@ -402,6 +463,14 @@ export class Popup {
: null;
}
if (this.customInputs?.length) {
this.inputResults = new Map(this.customInputs.map(input => {
/** @type {HTMLInputElement} */
const inputControl = this.dlg.querySelector(`#${input.id}`);
return [inputControl.id, inputControl.checked];
}));
}
this.value = value;
this.result = result;
@@ -410,15 +479,16 @@ export class Popup {
if (!shouldClose) return;
}
Popup.util.lastResult = { value, result };
this.hide();
Popup.util.lastResult = { value, result, inputResults: this.inputResults };
this.#hide();
return this.#promise;
}
/**
* Hides the popup, using the internal resolver to return the value to the original show promise
* @private
*/
hide() {
#hide() {
// We close the dialog, first running the animation
this.dlg.setAttribute('closing', '');
@@ -451,9 +521,9 @@ export class Popup {
else popup.setAutoFocus();
}
}
});
this.resolver(this.value);
this.#resolver(this.value);
});
}
/**
@@ -467,10 +537,10 @@ export class Popup {
* Contains the list of all currently open popups, and it'll remember the result of the last closed popup.
*/
static util = {
/** @type {Popup[]} Remember all popups */
/** @readonly @type {Popup[]} Remember all popups */
popups: [],
/** @type {{value: any, result: POPUP_RESULT|number?}?} Last popup result */
/** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map<string, boolean>?}?} Last popup result */
lastResult: null,
/** @returns {boolean} Checks if any modal popup dialog is open */
@@ -491,9 +561,17 @@ export class Popup {
}
class PopupUtils {
/**
* Builds popup content with header and text below
*
* @param {string} header - The header to be added to the text
* @param {string} text - The main text content
*/
static BuildTextWithHeader(header, text) {
return `
<h3>${header}</h1>
if (!header) {
return text;
}
return `<h3>${header}</h3>
${text}`;
}
}
@@ -555,8 +633,13 @@ export function fixToastrForDialogs() {
// Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists,
// but we don't have an open dialog to move it into. It's just inside the existing one that will be gone in milliseconds.
// To prevent new toasts from being showing up in there and then vanish in an instant,
// we move the toastr back to the main body
// we move the toastr back to the main body, or delete if its empty
if (!dlg && isAlreadyPresent) {
document.body.appendChild(toastContainer);
if (!toastContainer.childNodes.length) {
toastContainer.remove();
} else {
document.body.appendChild(toastContainer);
toastContainer.classList.add('toast-top-center');
}
}
}

View File

@@ -22,6 +22,7 @@ import {
setActiveGroup,
setActiveCharacter,
entitiesFilter,
doNewChat,
} from '../script.js';
import { isMobile, initMovingUI, favsToHotswap } from './RossAscends-mods.js';
import {
@@ -39,11 +40,11 @@ import { tokenizers } from './tokenizers.js';
import { BIAS_CACHE } from './logit-bias.js';
import { renderTemplateAsync } from './templates.js';
import { countOccurrences, debounce, delay, download, getFileText, isOdd, onlyUnique, resetScrollHeight, shuffle, sortMoments, stringToRange, timestampToMoment } from './utils.js';
import { countOccurrences, debounce, delay, download, getFileText, getStringHash, isOdd, isTrueBoolean, onlyUnique, resetScrollHeight, shuffle, sortMoments, stringToRange, timestampToMoment } from './utils.js';
import { FILTER_TYPES } from './filters.js';
import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { AUTOCOMPLETE_WIDTH } from './autocomplete/AutoComplete.js';
import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
@@ -178,6 +179,7 @@ let power_user = {
send_on_enter: send_on_enter_options.AUTO,
console_log_prompts: false,
request_token_probabilities: false,
show_group_chat_queue: false,
render_formulas: false,
allow_name1_display: false,
allow_name2_display: false,
@@ -334,6 +336,8 @@ const storage_keys = {
compact_input_area: 'compact_input_area',
auto_connect_legacy: 'AutoConnectEnabled',
auto_load_chat_legacy: 'AutoLoadChatEnabled',
storyStringValidationCache: 'StoryStringValidationCache',
};
const contextControls = [
@@ -1598,6 +1602,7 @@ function loadPowerUserSettings(settings, data) {
$('#console_log_prompts').prop('checked', power_user.console_log_prompts);
$('#request_token_probabilities').prop('checked', power_user.request_token_probabilities);
$('#show_group_chat_queue').prop('checked', power_user.show_group_chat_queue);
$('#auto_fix_generated_markdown').prop('checked', power_user.auto_fix_generated_markdown);
$('#auto_scroll_chat_to_bottom').prop('checked', power_user.auto_scroll_chat_to_bottom);
$('#bogus_folders').prop('checked', power_user.bogus_folders);
@@ -2104,6 +2109,9 @@ export function fuzzySearchGroups(searchValue) {
*/
export function renderStoryString(params) {
try {
// Validate and log possible warnings/errors
validateStoryString(power_user.context.story_string, params);
// compile the story string template into a function, with no HTML escaping
const compiledTemplate = Handlebars.compile(power_user.context.story_string, { noEscape: true });
@@ -2131,6 +2139,55 @@ export function renderStoryString(params) {
}
}
/**
* Validate the story string for possible warnings or issues
*
* @param {string} storyString - The story string
* @param {Object} params - The story string parameters
*/
function validateStoryString(storyString, params) {
/** @type {{hashCache: {[hash: string]: {fieldsWarned: {[key: string]: boolean}}}}} */
const cache = JSON.parse(localStorage.getItem(storage_keys.storyStringValidationCache)) ?? { hashCache: {} };
const hash = getStringHash(storyString);
// Initialize the cache for the current hash if it doesn't exist
if (!cache.hashCache[hash]) {
cache.hashCache[hash] = { fieldsWarned: {} };
}
const currentCache = cache.hashCache[hash];
const fieldsToWarn = [];
function validateMissingField(field, fallbackLegacyField = null) {
const contains = storyString.includes(`{{${field}}}`) || (!!fallbackLegacyField && storyString.includes(`{{${fallbackLegacyField}}}`));
if (!contains && params[field]) {
const wasLogged = currentCache.fieldsWarned[field];
if (!wasLogged) {
fieldsToWarn.push(field);
currentCache.fieldsWarned[field] = true;
}
console.warn(`The story string does not contain {{${field}}}, but it would contain content:\n`, params[field]);
}
}
validateMissingField('description');
validateMissingField('personality');
validateMissingField('persona');
validateMissingField('scenario');
validateMissingField('system');
validateMissingField('wiBefore', 'loreBefore');
validateMissingField('wiAfter', 'loreAfter');
if (fieldsToWarn.length > 0) {
const fieldsList = fieldsToWarn.map(field => `{{${field}}}`).join(', ');
toastr.warning(`The story string does not contain the following fields, but they would contain content: ${fieldsList}`, 'Story String Validation');
}
localStorage.setItem(storage_keys.storyStringValidationCache, JSON.stringify(cache));
}
const sortFunc = (a, b) => power_user.sort_order == 'asc' ? compareFunc(a, b) : compareFunc(b, a);
const compareFunc = (first, second) => {
const a = first[power_user.sort_field];
@@ -2315,26 +2372,30 @@ async function saveTheme(name = undefined, theme = undefined) {
body: JSON.stringify(theme),
});
if (response.ok) {
const themeIndex = themes.findIndex(x => x.name == name);
if (themeIndex == -1) {
themes.push(theme);
const option = document.createElement('option');
option.selected = true;
option.value = name;
option.innerText = name;
$('#themes').append(option);
}
else {
themes[themeIndex] = theme;
$(`#themes option[value="${name}"]`).attr('selected', true);
}
power_user.theme = name;
saveSettingsDebounced();
if (!response.ok) {
toastr.error('Check the server connection and reload the page to prevent data loss.', 'Theme could not be saved');
console.error('Theme could not be saved', response);
throw new Error('Theme could not be saved');
}
const themeIndex = themes.findIndex(x => x.name == name);
if (themeIndex == -1) {
themes.push(theme);
const option = document.createElement('option');
option.selected = true;
option.value = name;
option.innerText = name;
$('#themes').append(option);
}
else {
themes[themeIndex] = theme;
$(`#themes option[value="${name}"]`).attr('selected', true);
}
power_user.theme = name;
saveSettingsDebounced();
return theme;
}
@@ -2400,12 +2461,14 @@ function getNewTheme(parsed) {
}
async function saveMovingUI() {
const name = await callGenericPopup('Enter a name for the MovingUI Preset:', POPUP_TYPE.INPUT);
const popupResult = await callGenericPopup('Enter a name for the MovingUI Preset:', POPUP_TYPE.INPUT);
if (!name) {
if (!popupResult) {
return;
}
const name = String(popupResult);
const movingUIPreset = {
name,
movingUIState: power_user.movingUIState,
@@ -2437,7 +2500,8 @@ async function saveMovingUI() {
power_user.movingUIPreset = name;
saveSettingsDebounced();
} else {
toastr.warning('failed to save MovingUI state.');
toastr.error('Failed to save MovingUI state.');
console.error('MovingUI could not be saved', response);
}
}
@@ -2530,14 +2594,6 @@ async function resetMovablePanels(type) {
});
}
async function doNewChat() {
$('#option_start_new_chat').trigger('click');
await delay(1);
$('#dialogue_popup_ok').trigger('click');
await delay(1);
return '';
}
/**
* Finds the ID of the tag with the given name.
* @param {string} name
@@ -3513,6 +3569,11 @@ $(document).ready(() => {
saveSettingsDebounced();
});
$('#show_group_chat_queue').on('input', function () {
power_user.show_group_chat_queue = !!$(this).prop('checked');
saveSettingsDebounced();
});
$('#auto_scroll_chat_to_bottom').on('input', function () {
power_user.auto_scroll_chat_to_bottom = !!$(this).prop('checked');
saveSettingsDebounced();
@@ -3926,7 +3987,20 @@ $(document).ready(() => {
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'newchat',
callback: doNewChat,
/** @type {(args: { delete: string?}, string) => Promise<''>} */
callback: async (args, _) => {
await doNewChat({ deleteCurrentChat: isTrueBoolean(args.delete) });
return '';
},
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'delete',
description: 'delete the current chat',
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'false',
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
],
helpString: 'Start a new chat with the current character',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({

View File

@@ -182,17 +182,19 @@ class PresetManager {
async savePreset(name, settings) {
const preset = settings ?? this.getPresetSettings(name);
const res = await fetch('/api/presets/save', {
const response = await fetch('/api/presets/save', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ preset, name, apiId: this.apiId }),
});
if (!res.ok) {
toastr.error('Failed to save preset');
if (!response.ok) {
toastr.error('Check the server connection and reload the page to prevent data loss.', 'Preset could not be saved');
console.error('Preset could not be saved', response);
throw new Error('Preset could not be saved');
}
const data = await res.json();
const data = await response.json();
name = data.name;
this.updateList(name, preset);
@@ -327,6 +329,7 @@ class PresetManager {
'infermaticai_model',
'dreamgen_model',
'openrouter_model',
'featherless_model',
'max_tokens_second',
'openrouter_providers',
];

View File

@@ -28,6 +28,10 @@ export const SECRET_KEYS = {
PERPLEXITY: 'api_key_perplexity',
GROQ: 'api_key_groq',
AZURE_TTS: 'api_key_azure_tts',
FEATHERLESS: 'api_key_featherless',
ZEROONEAI: 'api_key_01ai',
HUGGINGFACE: 'api_key_huggingface',
STABILITY: 'api_key_stability',
};
const INPUT_MAP = {
@@ -56,6 +60,9 @@ const INPUT_MAP = {
[SECRET_KEYS.COHERE]: '#api_key_cohere',
[SECRET_KEYS.PERPLEXITY]: '#api_key_perplexity',
[SECRET_KEYS.GROQ]: '#api_key_groq',
[SECRET_KEYS.FEATHERLESS]: '#api_key_featherless',
[SECRET_KEYS.ZEROONEAI]: '#api_key_01ai',
[SECRET_KEYS.HUGGINGFACE]: '#api_key_huggingface',
};
async function clearSecret() {

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,7 @@ export class SlashCommandArgument {
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums
* @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} enumProvider function that returns auto complete options
*/
constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = true) {
constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = false) {
this.description = description;
this.typeList = types ? Array.isArray(types) ? types : [types] : [];
this.isRequired = isRequired ?? false;
@@ -90,7 +90,7 @@ export class SlashCommandNamedArgument extends SlashCommandArgument {
* @param {string|SlashCommandClosure} [props.defaultValue=null] default value if no value is provided
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList=[]] list of accepted values
* @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} [props.enumProvider=null] function that returns auto complete options
* @param {boolean} [props.forceEnum=true] default: true - whether the input must match one of the enum values
* @param {boolean} [props.forceEnum=false] default: false - whether the input must match one of the enum values
*/
static fromProps(props) {
return new SlashCommandNamedArgument(
@@ -103,7 +103,7 @@ export class SlashCommandNamedArgument extends SlashCommandArgument {
props.enumList ?? [],
props.aliasList ?? [],
props.enumProvider ?? null,
props.forceEnum ?? true,
props.forceEnum ?? false,
);
}
@@ -120,9 +120,9 @@ export class SlashCommandNamedArgument extends SlashCommandArgument {
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [enums=[]]
* @param {string[]} [aliases=[]]
* @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} [enumProvider=null] function that returns auto complete options
* @param {boolean} [forceEnum=true]
* @param {boolean} [forceEnum=false]
*/
constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = true) {
constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = false) {
super(description, types, isRequired, acceptsMultiple, defaultValue, enums, enumProvider, forceEnum);
this.name = name;
this.aliasList = aliases ? Array.isArray(aliases) ? aliases : [aliases] : [];

View File

@@ -160,7 +160,7 @@ async function* parseStreamData(json) {
return;
}
// llama.cpp?
else if (typeof json.content === 'string' && json.content.length > 0) {
else if (typeof json.content === 'string' && json.content.length > 0 && json.object !== 'chat.completion.chunk') {
for (let i = 0; i < json.content.length; i++) {
const str = json.content[i];
yield {

View File

@@ -801,8 +801,7 @@ async function showTagImportPopup(character, existingTags, newTags, folderTags)
if (folderTags.length === 0) popupContent.find('#folder_tags_block').hide();
function onCloseRemember(/** @type {Popup} */ popup) {
const rememberCheckbox = document.getElementById('import_remember_option');
if (rememberCheckbox instanceof HTMLInputElement && rememberCheckbox.checked) {
if (popup.result && popup.inputResults.get('import_remember_option')) {
const setting = buttonSettingsMap[popup.result];
if (!setting) return;
power_user.tag_import_setting = setting;
@@ -812,7 +811,12 @@ async function showTagImportPopup(character, existingTags, newTags, folderTags)
}
}
const result = await callGenericPopup(popupContent, POPUP_TYPE.TEXT, null, { wider: true, okButton: 'Import', cancelButton: true, customButtons: Object.values(importButtons), onClose: onCloseRemember });
const result = await callGenericPopup(popupContent, POPUP_TYPE.TEXT, null, {
wider: true, okButton: 'Import', cancelButton: true,
customButtons: Object.values(importButtons),
customInputs: [{ id: 'import_remember_option', label: 'Remember my choice', tooltip: 'Remember the chosen import option\nIf anything besides \'Cancel\' is selected, this dialog will not show up anymore.\nTo change this, go to the settings and modify "Tag Import Option".\n\nIf the "Import" option is chosen, the global setting will stay on "Ask".' }],
onClose: onCloseRemember
});
if (!result) {
return [];
}

View File

@@ -19,17 +19,4 @@
</small>
<div id="import_folder_tags_list" class="tags" style="margin-top: 5px;"></div>
</div>
<small>
<label class="checkbox flex-container alignitemscenter flexNoGap m-t-3" for="import_remember_option">
<input type="checkbox" id="import_remember_option" name="import_remember_option" />
<span>
<span data-i18n="Remember my choice">Remember my choice</span>
<div class="fa-solid fa-circle-info opacity50p"
data-i18n="[title]Remember the chosen import option If anything besides 'Cancel' is selected, this dialog will not show up anymore. To change this, go to the settings and modify &quot;Tag Import Option&quot;. If the &quot;Import&quot; option is chosen, the global setting will stay on &quot;Ask&quot;."
title="Remember the chosen import option&#010;If anything besides 'Cancel' is selected, this dialog will not show up anymore.&#010;To change this, go to the settings and modify &quot;Tag Import Option&quot;.&#010;&#010;If the &quot;Import&quot; option is chosen, the global setting will stay on &quot;Ask&quot;.">
</div>
</span>
</label>
</small>
</div>

View File

@@ -2,10 +2,10 @@
<select id="{{prefix}}prompt_manager_footer_append_prompt" class="text_pole" name="append-prompt">
{{{promptsHtml}}}
</select>
<a class="menu_button fa-chain fa-solid" title="Insert prompt" data-i18n="[title]Insert prompt"></a>
<a class="caution menu_button fa-x fa-solid" title="Delete prompt" data-i18n="[title]Delete prompt"></a>
<a class="menu_button fa-file-import fa-solid" id="prompt-manager-import" title="Import a prompt list" data-i18n="[title]Import a prompt list"></a>
<a class="menu_button fa-file-export fa-solid" id="prompt-manager-export" title="Export this prompt list" data-i18n="[title]Export this prompt list"></a>
<a class="menu_button fa-undo fa-solid" id="prompt-manager-reset-character" title="Reset current character" data-i18n="[title]Reset current character"></a>
<a class="menu_button fa-plus-square fa-solid" title="New prompt" data-i18n="[title]New prompt"></a>
<a class="menu_button fa-chain fa-solid fa-fw" title="Insert prompt" data-i18n="[title]Insert prompt"></a>
<a class="caution menu_button fa-x fa-solid fa-fw" title="Delete prompt" data-i18n="[title]Delete prompt"></a>
<a class="menu_button fa-file-import fa-solid fa-fw" id="prompt-manager-import" title="Import a prompt list" data-i18n="[title]Import a prompt list"></a>
<a class="menu_button fa-file-export fa-solid fa-fw" id="prompt-manager-export" title="Export this prompt list" data-i18n="[title]Export this prompt list"></a>
<a class="menu_button fa-undo fa-solid fa-fw" id="prompt-manager-reset-character" title="Reset current character" data-i18n="[title]Reset current character"></a>
<a class="menu_button fa-plus-square fa-solid fa-fw" title="New prompt" data-i18n="[title]New prompt"></a>
</div>

View File

@@ -9,6 +9,7 @@ let infermaticAIModels = [];
let dreamGenModels = [];
let vllmModels = [];
let aphroditeModels = [];
let featherlessModels = [];
export let openRouterModels = [];
/**
@@ -233,6 +234,35 @@ export async function loadAphroditeModels(data) {
}
}
export async function loadFeatherlessModels(data) {
if (!Array.isArray(data)) {
console.error('Invalid Featherless models data', data);
return;
}
featherlessModels = data;
if (!data.find(x => x.id === textgen_settings.featherless_model)) {
textgen_settings.featherless_model = data[0]?.id || '';
}
$('#featherless_model').empty();
for (const model of data) {
const option = document.createElement('option');
option.value = model.id;
option.text = model.id;
option.selected = model.id === textgen_settings.featherless_model;
$('#featherless_model').append(option);
}
}
function onFeatherlessModelSelect() {
const modelId = String($('#featherless_model').val());
textgen_settings.featherless_model = modelId;
$('#api_button_textgenerationwebui').trigger('click');
}
function onMancerModelSelect() {
const modelId = String($('#mancer_model').val());
textgen_settings.mancer_model = modelId;
@@ -507,6 +537,7 @@ jQuery(function () {
$('#ollama_download_model').on('click', downloadOllamaModel);
$('#vllm_model').on('change', onVllmModelSelect);
$('#aphrodite_model').on('change', onAphroditeModelSelect);
$('#featherless_model').on('change', onFeatherlessModelSelect);
const providersSelect = $('.openrouter_providers');
for (const provider of OPENROUTER_PROVIDERS) {
@@ -572,6 +603,12 @@ jQuery(function () {
width: '100%',
templateResult: getAphroditeModelTemplate,
});
$('#featherless_model').select2({
placeholder: 'Select a model',
searchInputPlaceholder: 'Search models...',
searchInputCssClass: 'text_pole',
width: '100%',
});
providersSelect.select2({
sorter: data => data.sort((a, b) => a.text.localeCompare(b.text)),
placeholder: 'Select providers. No selection = all providers.',

View File

@@ -38,9 +38,26 @@ export const textgen_types = {
INFERMATICAI: 'infermaticai',
DREAMGEN: 'dreamgen',
OPENROUTER: 'openrouter',
FEATHERLESS: 'featherless',
HUGGINGFACE: 'huggingface',
};
const { MANCER, VLLM, APHRODITE, TABBY, TOGETHERAI, OOBA, OLLAMA, LLAMACPP, INFERMATICAI, DREAMGEN, OPENROUTER, KOBOLDCPP } = textgen_types;
const {
MANCER,
VLLM,
APHRODITE,
TABBY,
TOGETHERAI,
OOBA,
OLLAMA,
LLAMACPP,
INFERMATICAI,
DREAMGEN,
OPENROUTER,
KOBOLDCPP,
HUGGINGFACE,
FEATHERLESS,
} = textgen_types;
const LLAMACPP_DEFAULT_ORDER = [
'top_k',
@@ -75,6 +92,7 @@ let TOGETHERAI_SERVER = 'https://api.together.xyz';
let INFERMATICAI_SERVER = 'https://api.totalgpt.ai';
let DREAMGEN_SERVER = 'https://dreamgen.com';
let OPENROUTER_SERVER = 'https://openrouter.ai/api';
let FEATHERLESS_SERVER = 'https://api.featherless.ai/v1';
const SERVER_INPUTS = {
[textgen_types.OOBA]: '#textgenerationwebui_api_url_text',
@@ -84,6 +102,7 @@ const SERVER_INPUTS = {
[textgen_types.KOBOLDCPP]: '#koboldcpp_api_url_text',
[textgen_types.LLAMACPP]: '#llamacpp_api_url_text',
[textgen_types.OLLAMA]: '#ollama_api_url_text',
[textgen_types.HUGGINGFACE]: '#huggingface_api_url_text',
};
const KOBOLDCPP_ORDER = [6, 0, 1, 3, 4, 2, 5];
@@ -244,6 +263,8 @@ export const setting_names = [
'bypass_status_check',
];
const DYNATEMP_BLOCK = document.getElementById('dynatemp_block_ooba');
export function validateTextGenUrl() {
const selector = SERVER_INPUTS[settings.type];
@@ -265,6 +286,8 @@ export function validateTextGenUrl() {
export function getTextGenServer() {
switch (settings.type) {
case FEATHERLESS:
return FEATHERLESS_SERVER;
case MANCER:
return MANCER_SERVER;
case TOGETHERAI:
@@ -1009,6 +1032,10 @@ export function getTextGenModel() {
throw new Error('No Ollama model selected');
}
return settings.ollama_model;
case FEATHERLESS:
return settings.featherless_model;
case HUGGINGFACE:
return 'tgi';
default:
return undefined;
}
@@ -1020,6 +1047,10 @@ export function isJsonSchemaSupported() {
return [TABBY, LLAMACPP].includes(settings.type) && main_api === 'textgenerationwebui';
}
function isDynamicTemperatureSupported() {
return settings.dynatemp && DYNATEMP_BLOCK?.dataset?.tgType?.includes(settings.type);
}
function getLogprobsNumber() {
if (settings.type === VLLM || settings.type === INFERMATICAI) {
return 5;
@@ -1030,6 +1061,7 @@ function getLogprobsNumber() {
export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, isContinue, cfgValues, type) {
const canMultiSwipe = !isContinue && !isImpersonate && type !== 'quiet';
const dynatemp = isDynamicTemperatureSupported();
const { banned_tokens, banned_strings } = getCustomTokenBans();
let params = {
@@ -1038,7 +1070,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
'max_new_tokens': maxTokens,
'max_tokens': maxTokens,
'logprobs': power_user.request_token_probabilities ? getLogprobsNumber() : undefined,
'temperature': settings.dynatemp ? (settings.min_temp + settings.max_temp) / 2 : settings.temp,
'temperature': dynatemp ? (settings.min_temp + settings.max_temp) / 2 : settings.temp,
'top_p': settings.top_p,
'typical_p': settings.typical_p,
'typical': settings.typical_p,
@@ -1056,11 +1088,11 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
'length_penalty': settings.length_penalty,
'early_stopping': settings.early_stopping,
'add_bos_token': settings.add_bos_token,
'dynamic_temperature': settings.dynatemp ? true : undefined,
'dynatemp_low': settings.dynatemp ? settings.min_temp : undefined,
'dynatemp_high': settings.dynatemp ? settings.max_temp : undefined,
'dynatemp_range': settings.dynatemp ? (settings.max_temp - settings.min_temp) / 2 : undefined,
'dynatemp_exponent': settings.dynatemp ? settings.dynatemp_exponent : undefined,
'dynamic_temperature': dynatemp ? true : undefined,
'dynatemp_low': dynatemp ? settings.min_temp : undefined,
'dynatemp_high': dynatemp ? settings.max_temp : undefined,
'dynatemp_range': dynatemp ? (settings.max_temp - settings.min_temp) / 2 : undefined,
'dynatemp_exponent': dynatemp ? settings.dynatemp_exponent : undefined,
'smoothing_factor': settings.smoothing_factor,
'smoothing_curve': settings.smoothing_curve,
'dry_allowed_length': settings.dry_allowed_length,
@@ -1146,6 +1178,12 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
params.grammar = settings.grammar_string;
}
if (settings.type === HUGGINGFACE) {
params.top_p = Math.min(Math.max(Number(params.top_p), 0.0), 0.999);
params.stop = Array.isArray(params.stop) ? params.stop.slice(0, 4) : [];
nonAphroditeParams.seed = settings.seed >= 0 ? settings.seed : undefined;
}
if (settings.type === MANCER) {
params.n = canMultiSwipe ? settings.n : 1;
params.epsilon_cutoff /= 1000;

View File

@@ -545,6 +545,10 @@ export function getTokenizerModel() {
}
}
if (oai_settings.chat_completion_source === chat_completion_sources.ZEROONEAI) {
return yiTokenizer;
}
// Default to Turbo 3.5
return turboTokenizer;
}

View File

@@ -592,7 +592,7 @@ async function viewSettingsSnapshots() {
}
}
callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false });
callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: false, large: false, allowVerticalScrolling: true });
template.find('.makeSnapshotButton').on('click', () => makeSnapshot(renderSnapshots));
renderSnapshots();
}

View File

@@ -803,7 +803,7 @@ export function getImageSizeFromDataURL(dataUrl) {
export function getCharaFilename(chid) {
const context = getContext();
const fileName = context.characters[chid ?? context.characterId].avatar;
const fileName = context.characters[chid ?? context.characterId]?.avatar;
if (fileName) {
return fileName.replace(/\.[^/.]+$/, '');

View File

@@ -1358,7 +1358,11 @@ export function registerVariableCommands() {
name: 'times',
callback: timesCallback,
returns: 'result of the last executed command',
namedArgumentList: [],
namedArgumentList: [
new SlashCommandNamedArgument(
'guard', 'disable loop iteration limit', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOff')(),
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'repeats',

View File

@@ -24,6 +24,7 @@ export {
world_info_depth,
world_info_min_activations,
world_info_min_activations_depth_max,
world_info_include_names,
world_info_recursive,
world_info_overflow_alert,
world_info_case_sensitive,
@@ -50,6 +51,28 @@ const world_info_logic = {
AND_ALL: 3,
};
/**
* @enum {number} Possible states of the WI evaluation
*/
const scan_state = {
/**
* The scan will be stopped.
*/
NONE: 0,
/**
* Initial state.
*/
INITIAL: 1,
/**
* The scan is triggered by a recursion step.
*/
RECURSION: 2,
/**
* The scan is triggered by a min activations depth skew.
*/
MIN_ACTIVATIONS: 3,
};
const WI_ENTRY_EDIT_TEMPLATE = $('#entry_edit_template .world_entry');
let world_info = {};
@@ -61,6 +84,7 @@ let world_info_min_activations = 0; // if > 0, will continue seeking chat until
let world_info_min_activations_depth_max = 0; // used when (world_info_min_activations > 0)
let world_info_budget = 25;
let world_info_include_names = true;
let world_info_recursive = false;
let world_info_overflow_alert = false;
let world_info_case_sensitive = false;
@@ -99,6 +123,7 @@ const MAX_SCAN_DEPTH = 1000;
* @property {number} [selectiveLogic] The logic to use for selective activation
* @property {number} [sticky] The sticky value of the entry
* @property {number} [cooldown] The cooldown of the entry
* @property {number} [delay] The delay of the entry
*/
/**
@@ -111,7 +136,7 @@ const MAX_SCAN_DEPTH = 1000;
/**
* @typedef TimedEffectType Type of timed effect
* @type {'sticky'|'cooldown'}
* @type {'sticky'|'cooldown'|'delay'}
*/
// End typedef area
@@ -134,6 +159,11 @@ class WorldInfoBuffer {
*/
#recurseBuffer = [];
/**
* @type {string[]} Array of strings added by prompt injections that are valid for the current scan
*/
#injectBuffer = [];
/**
* @type {number} The skew of the global scan depth. Used in "min activations"
*/
@@ -183,9 +213,10 @@ class WorldInfoBuffer {
/**
* Gets all messages up to the given depth + recursion buffer.
* @param {WIScanEntry} entry The entry that triggered the scan
* @param {number} scanState The state of the scan
* @returns {string} A slice of buffer until the given depth (inclusive)
*/
get(entry) {
get(entry, scanState) {
let depth = entry.scanDepth ?? this.getDepth();
if (depth <= this.#startDepth) {
return '';
@@ -203,7 +234,12 @@ class WorldInfoBuffer {
let result = this.#depthBuffer.slice(this.#startDepth, depth).join('\n');
if (this.#recurseBuffer.length > 0) {
if (this.#injectBuffer.length > 0) {
result += '\n' + this.#injectBuffer.join('\n');
}
// Min activations should not include the recursion buffer
if (this.#recurseBuffer.length > 0 && scanState !== scan_state.MIN_ACTIVATIONS) {
result += '\n' + this.#recurseBuffer.join('\n');
}
@@ -257,6 +293,14 @@ class WorldInfoBuffer {
this.#recurseBuffer.push(message);
}
/**
* Adds an injection to the buffer.
* @param {string} message The injection to add
*/
addInject(message) {
this.#injectBuffer.push(message);
}
/**
* Increments skew and sets startDepth to previous depth.
*/
@@ -292,10 +336,11 @@ class WorldInfoBuffer {
/**
* Gets the match score for the given entry.
* @param {WIScanEntry} entry Entry to check
* @param {number} scanState The state of the scan
* @returns {number} The number of key activations for the given entry
*/
getScore(entry) {
const bufferState = this.get(entry);
getScore(entry, scanState) {
const bufferState = this.get(entry, scanState);
let numberOfPrimaryKeys = 0;
let numberOfSecondaryKeys = 0;
let primaryScore = 0;
@@ -371,6 +416,7 @@ class WorldInfoTimedEffects {
#buffer = {
'sticky': [],
'cooldown': [],
'delay': [],
};
/**
@@ -404,6 +450,8 @@ class WorldInfoTimedEffects {
'cooldown': (entry) => {
console.debug('Cooldown ended for entry', entry.uid);
},
'delay': () => {},
};
/**
@@ -529,12 +577,31 @@ class WorldInfoTimedEffects {
}
}
/**
* Processes entries for the "delay" timed effect.
* @param {WIScanEntry[]} buffer Buffer to store the entries
*/
#checkDelayEffect(buffer) {
for (const entry of this.#entries) {
if (!entry.delay) {
continue;
}
if (this.#chat.length < entry.delay) {
buffer.push(entry);
console.log('Timed effect "delay" applied to entry', entry);
}
}
}
/**
* Checks for timed effects on chat messages.
*/
checkTimedEffects() {
this.#checkTimedEffectOfType('sticky', this.#buffer.sticky, this.#onEnded.sticky.bind(this));
this.#checkTimedEffectOfType('cooldown', this.#buffer.cooldown, this.#onEnded.cooldown.bind(this));
this.#checkDelayEffect(this.#buffer.delay);
}
/**
@@ -611,7 +678,7 @@ class WorldInfoTimedEffects {
* @returns {boolean} Is recognized type
*/
isValidEffectType(type) {
return typeof type === 'string' && ['sticky', 'cooldown'].includes(type.trim().toLowerCase());
return typeof type === 'string' && ['sticky', 'cooldown', 'delay'].includes(type.trim().toLowerCase());
}
/**
@@ -645,6 +712,7 @@ export function getWorldInfoSettings() {
world_info_min_activations,
world_info_min_activations_depth_max,
world_info_budget,
world_info_include_names,
world_info_recursive,
world_info_overflow_alert,
world_info_case_sensitive,
@@ -674,7 +742,7 @@ const worldInfoCache = new Map();
/**
* Gets the world info based on chat messages.
* @param {string[]} chat The chat messages to scan.
* @param {string[]} chat The chat messages to scan, in reverse order.
* @param {number} maxContext The maximum context size of the generation.
* @param {boolean} isDryRun If true, the function will not emit any events.
* @typedef {{worldInfoString: string, worldInfoBefore: string, worldInfoAfter: string, worldInfoExamples: any[], worldInfoDepth: any[]}} WIPromptResult
@@ -711,6 +779,8 @@ function setWorldInfoSettings(settings, data) {
world_info_min_activations_depth_max = Number(settings.world_info_min_activations_depth_max);
if (settings.world_info_budget !== undefined)
world_info_budget = Number(settings.world_info_budget);
if (settings.world_info_include_names !== undefined)
world_info_include_names = Boolean(settings.world_info_include_names);
if (settings.world_info_recursive !== undefined)
world_info_recursive = Boolean(settings.world_info_recursive);
if (settings.world_info_overflow_alert !== undefined)
@@ -760,6 +830,7 @@ function setWorldInfoSettings(settings, data) {
$('#world_info_budget_counter').val(world_info_budget);
$('#world_info_budget').val(world_info_budget);
$('#world_info_include_names').prop('checked', world_info_include_names);
$('#world_info_recursive').prop('checked', world_info_recursive);
$('#world_info_overflow_alert').prop('checked', world_info_overflow_alert);
$('#world_info_case_sensitive').prop('checked', world_info_case_sensitive);
@@ -1685,7 +1756,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl
// 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 Popup.show.confirm(`Delete the World/Lorebook: "${name}"?`, `This action is irreversible!`);
const confirmation = await Popup.show.confirm(`Delete the World/Lorebook: "${name}"?`, 'This action is irreversible!');
if (!confirmation) {
return;
}
@@ -1733,14 +1804,21 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl
return entriesArray;
}
const storageKey = 'WI_PerPage';
const perPageDefault = 25;
let startPage = 1;
if (navigation === navigation_option.previous) {
startPage = $('#world_info_pagination').pagination('getCurrentPageNum');
}
const storageKey = 'WI_PerPage';
const perPageDefault = 25;
if (typeof navigation === 'number' && Number(navigation) >= 0) {
const data = getDataArray();
const uidIndex = data.findIndex(x => x.uid === navigation);
const perPage = Number(localStorage.getItem(storageKey)) || perPageDefault;
startPage = Math.floor(uidIndex / perPage) + 1;
}
$('#world_info_pagination').pagination({
dataSource: getDataArray,
pageSize: Number(localStorage.getItem(storageKey)) || perPageDefault,
@@ -1801,15 +1879,8 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl
},
});
if (typeof navigation === 'number' && Number(navigation) >= 0) {
const selector = `#world_popup_entries_list [uid="${navigation}"]`;
const data = getDataArray();
const uidIndex = data.findIndex(x => x.uid === navigation);
const perPage = Number(localStorage.getItem(storageKey)) || perPageDefault;
const page = Math.floor(uidIndex / perPage) + 1;
$('#world_info_pagination').pagination('go', page);
waitUntilCondition(() => document.querySelector(selector) !== null).finally(() => {
const element = $(selector);
@@ -1941,6 +2012,7 @@ const originalDataKeyMap = {
'groupWeight': 'extensions.group_weight',
'sticky': 'extensions.sticky',
'cooldown': 'extensions.cooldown',
'delay': 'extensions.delay',
};
/** Checks the state of the current search, and adds/removes the search sorting option accordingly */
@@ -2589,6 +2661,19 @@ function getWorldEntry(name, data, entry) {
});
cooldown.val(entry.cooldown > 0 ? entry.cooldown : '').trigger('input');
// delay
const delay = template.find('input[name="delay"]');
delay.data('uid', entry.uid);
delay.on('input', function () {
const uid = $(this).data('uid');
const value = Number($(this).val());
data.entries[uid].delay = !isNaN(value) ? value : null;
setOriginalDataValue(data, uid, 'extensions.delay', data.entries[uid].delay);
saveWorldInfo(name, data);
});
delay.val(entry.delay > 0 ? entry.delay : '').trigger('input');
// probability
if (entry.probability === undefined) {
entry.probability = null;
@@ -3139,6 +3224,7 @@ const newEntryDefinition = {
role: { default: 0, type: 'enum' },
sticky: { default: null, type: 'number?' },
cooldown: { default: null, type: 'number?' },
delay: { default: null, type: 'number?' },
};
const newEntryTemplate = Object.fromEntries(
@@ -3447,7 +3533,7 @@ export async function getSortedEntries() {
/**
* Performs a scan on the chat and returns the world info activated.
* @param {string[]} chat The chat messages to scan.
* @param {string[]} chat The chat messages to scan, in reverse order.
* @param {number} maxContext The maximum context size of the generation.
* @param {boolean} isDryRun Whether to perform a dry run.
* @typedef {{ worldInfoBefore: string, worldInfoAfter: string, EMEntries: any[], WIDepthEntries: any[], allActivatedEntries: Set<any> }} WIActivated
@@ -3465,12 +3551,12 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
if (context.extensionPrompts[key]?.scan) {
const prompt = getExtensionPromptByName(key);
if (prompt) {
buffer.addRecurse(prompt);
buffer.addInject(prompt);
}
}
}
let needsToScan = true;
let scanState = scan_state.INITIAL;
let token_budget_overflowed = false;
let count = 0;
let allActivatedEntries = new Set();
@@ -3494,8 +3580,9 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], allActivatedEntries: new Set() };
}
while (needsToScan) {
// Track how many times the loop has run
while (scanState) {
// Track how many times the loop has run. May be useful for debugging.
// eslint-disable-next-line no-unused-vars
count++;
let activatedNow = new Set();
@@ -3533,9 +3620,15 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
const isSticky = timedEffects.isEffectActive('sticky', entry);
const isCooldown = timedEffects.isEffectActive('cooldown', entry);
const isDelay = timedEffects.isEffectActive('delay', entry);
if (isDelay) {
console.debug(`WI entry ${entry.uid} suppressed by delay`, entry);
continue;
}
if (isCooldown && !isSticky) {
console.debug(`WI entry ${entry.uid} suppressed by cooldown`);
console.debug(`WI entry ${entry.uid} suppressed by cooldown`, entry);
continue;
}
@@ -3543,7 +3636,18 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
continue;
}
if (allActivatedEntries.has(entry) || entry.disable == true || (count > 1 && world_info_recursive && entry.excludeRecursion) || (count == 1 && entry.delayUntilRecursion)) {
if (allActivatedEntries.has(entry) || entry.disable == true) {
continue;
}
// Only use checks for recursion flags if the scan step was activated by recursion
if (scanState !== scan_state.RECURSION && entry.delayUntilRecursion) {
console.debug(`WI entry ${entry.uid} suppressed by delay until recursion`, entry);
continue;
}
if (scanState === scan_state.RECURSION && world_info_recursive && entry.excludeRecursion) {
console.debug(`WI entry ${entry.uid} suppressed by exclude recursion`, entry);
continue;
}
@@ -3558,7 +3662,7 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
primary: for (let key of entry.key) {
const substituted = substituteParams(key);
const textToScan = buffer.get(entry);
const textToScan = buffer.get(entry, scanState);
if (substituted && buffer.matchKeys(textToScan, substituted.trim(), entry)) {
console.debug(`WI UID ${entry.uid} found by primary match: ${substituted}.`);
@@ -3621,14 +3725,14 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
}
}
needsToScan = world_info_recursive && activatedNow.size > 0;
scanState = world_info_recursive && activatedNow.size > 0 ? scan_state.RECURSION : scan_state.NONE;
const newEntries = [...activatedNow]
.sort((a, b) => sortedEntries.indexOf(a) - sortedEntries.indexOf(b));
let newContent = '';
const textToScanTokens = await getTokenCountAsync(allActivatedText);
const probabilityChecksBefore = failedProbabilityChecks.size;
filterByInclusionGroups(newEntries, allActivatedEntries, buffer);
filterByInclusionGroups(newEntries, allActivatedEntries, buffer, scanState);
console.debug('-- PROBABILITY CHECKS BEGIN --');
for (const entry of newEntries) {
@@ -3653,7 +3757,7 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
console.log('Alerting');
toastr.warning(`World info budget reached after ${allActivatedEntries.size} entries.`, 'World Info');
}
needsToScan = false;
scanState = scan_state.NONE;
token_budget_overflowed = true;
break;
}
@@ -3666,15 +3770,15 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
if ((probabilityChecksAfter - probabilityChecksBefore) === activatedNow.size) {
console.debug('WI probability checks failed for all activated entries, stopping');
needsToScan = false;
scanState = scan_state.NONE;
}
if (newEntries.length === 0) {
console.debug('No new entries activated, stopping');
needsToScan = false;
scanState = scan_state.NONE;
}
if (needsToScan) {
if (scanState) {
const text = newEntries
.filter(x => !failedProbabilityChecks.has(x))
.filter(x => !x.preventRecursion)
@@ -3684,7 +3788,7 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
}
// world_info_min_activations
if (!needsToScan && !token_budget_overflowed) {
if (!scanState && !token_budget_overflowed) {
if (world_info_min_activations > 0 && (allActivatedEntries.size < world_info_min_activations)) {
let over_max = (
world_info_min_activations_depth_max > 0 &&
@@ -3692,7 +3796,7 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
) || (buffer.getDepth() > chat.length);
if (!over_max) {
needsToScan = true; // loop
scanState = scan_state.MIN_ACTIVATIONS; // loop
buffer.advanceScanPosition();
}
}
@@ -3780,8 +3884,9 @@ async function checkWorldInfo(chat, maxContext, isDryRun) {
* @param {Record<string, WIScanEntry[]>} groups The groups to filter
* @param {WorldInfoBuffer} buffer The buffer to use for scoring
* @param {(entry: WIScanEntry) => void} removeEntry The function to remove an entry
* @param {number} scanState The current scan state
*/
function filterGroupsByScoring(groups, buffer, removeEntry) {
function filterGroupsByScoring(groups, buffer, removeEntry, scanState) {
for (const [key, group] of Object.entries(groups)) {
// Group scoring is disabled both globally and for the group entries
if (!world_info_use_group_scoring && !group.some(x => x.useGroupScoring)) {
@@ -3789,7 +3894,7 @@ function filterGroupsByScoring(groups, buffer, removeEntry) {
continue;
}
const scores = group.map(entry => buffer.getScore(entry));
const scores = group.map(entry => buffer.getScore(entry, scanState));
const maxScore = Math.max(...scores);
console.debug(`Group '${key}' max score: ${maxScore}`);
//console.table(group.map((entry, i) => ({ uid: entry.uid, key: JSON.stringify(entry.key), score: scores[i] })));
@@ -3817,8 +3922,9 @@ function filterGroupsByScoring(groups, buffer, removeEntry) {
* @param {object[]} newEntries Entries activated on current recursion level
* @param {Set<object>} allActivatedEntries Set of all activated entries
* @param {WorldInfoBuffer} buffer The buffer to use for scanning
* @param {number} scanState The current scan state
*/
function filterByInclusionGroups(newEntries, allActivatedEntries, buffer) {
function filterByInclusionGroups(newEntries, allActivatedEntries, buffer, scanState) {
console.debug('-- INCLUSION GROUP CHECKS BEGIN --');
const grouped = newEntries.filter(x => x.group).reduce((acc, item) => {
item.group.split(/,\s*/).filter(x => x).forEach(group => {
@@ -3847,7 +3953,7 @@ function filterByInclusionGroups(newEntries, allActivatedEntries, buffer) {
}
}
filterGroupsByScoring(grouped, buffer, removeEntry);
filterGroupsByScoring(grouped, buffer, removeEntry, scanState);
for (const [key, group] of Object.entries(grouped)) {
console.debug(`Checking inclusion group '${key}' with ${group.length} entries`, group);
@@ -3933,6 +4039,7 @@ function convertAgnaiMemoryBook(inputObj) {
role: extension_prompt_roles.SYSTEM,
sticky: null,
cooldown: null,
delay: null,
};
});
@@ -3974,6 +4081,7 @@ function convertRisuLorebook(inputObj) {
role: extension_prompt_roles.SYSTEM,
sticky: null,
cooldown: null,
delay: null,
};
});
@@ -4020,6 +4128,7 @@ function convertNovelLorebook(inputObj) {
role: extension_prompt_roles.SYSTEM,
sticky: null,
cooldown: null,
delay: null,
};
});
@@ -4068,6 +4177,7 @@ function convertCharacterBook(characterBook) {
vectorized: entry.extensions?.vectorized ?? false,
sticky: entry.extensions?.sticky ?? null,
cooldown: entry.extensions?.cooldown ?? null,
delay: entry.extensions?.delay ?? null,
};
});
@@ -4429,6 +4539,11 @@ jQuery(() => {
saveSettings();
});
$('#world_info_include_names').on('input', function () {
world_info_include_names = !!$(this).prop('checked');
saveSettings();
});
$('#world_info_recursive').on('input', function () {
world_info_recursive = !!$(this).prop('checked');
saveSettings();