Merge branch 'staging' into silerott-add-session-handling

This commit is contained in:
Cohee
2023-11-28 18:25:31 +02:00
118 changed files with 13535 additions and 3921 deletions

View File

@ -67,7 +67,7 @@ function downloadAssetsList(url) {
const asset = availableAssets[assetType][i];
const elemId = `assets_install_${assetType}_${i}`;
let element = $('<button />', { id: elemId, type: "button", class: "asset-download-button menu_button" })
const label = $("<i class=\"fa-solid fa-download fa-xl\"></i>");
const label = $("<i class=\"fa-fw fa-solid fa-download fa-xl\"></i>");
element.append(label);
//if (DEBUG_TONY_SAMA_FORK_MODE)

View File

@ -1,17 +1,57 @@
import { getBase64Async, saveBase64AsFile } from "../../utils.js";
import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules } from "../../extensions.js";
import { callPopup, getRequestHeaders, saveSettingsDebounced } from "../../../script.js";
import { callPopup, getRequestHeaders, saveSettingsDebounced, substituteParams } from "../../../script.js";
import { getMessageTimeStamp } from "../../RossAscends-mods.js";
import { SECRET_KEYS, secret_state } from "../../secrets.js";
import { getMultimodalCaption } from "../shared.js";
export { MODULE_NAME };
const MODULE_NAME = 'caption';
const UPDATE_INTERVAL = 1000;
async function moduleWorker() {
const hasConnection = getContext().onlineStatus !== 'no_connection';
$('#send_picture').toggle(hasConnection);
const PROMPT_DEFAULT = 'Whats in this image?';
const TEMPLATE_DEFAULT = '[{{user}} sends {{char}} a picture that contains: {{caption}}]';
/**
* Migrates old extension settings to the new format.
* Must keep this function for compatibility with old settings.
*/
function migrateSettings() {
if (extension_settings.caption.local !== undefined) {
extension_settings.caption.source = extension_settings.caption.local ? 'local' : 'extras';
}
delete extension_settings.caption.local;
if (!extension_settings.caption.source) {
extension_settings.caption.source = 'extras';
}
if (extension_settings.caption.source === 'openai') {
extension_settings.caption.source = 'multimodal';
extension_settings.caption.multimodal_api = 'openai';
extension_settings.caption.multimodal_model = 'gpt-4-vision-preview';
}
if (!extension_settings.caption.multimodal_api) {
extension_settings.caption.multimodal_api = 'openai';
}
if (!extension_settings.caption.multimodal_model) {
extension_settings.caption.multimodal_model = 'gpt-4-vision-preview';
}
if (!extension_settings.caption.prompt) {
extension_settings.caption.prompt = PROMPT_DEFAULT;
}
if (!extension_settings.caption.template) {
extension_settings.caption.template = TEMPLATE_DEFAULT;
}
}
/**
* Sets an image icon for the send button.
*/
async function setImageIcon() {
try {
const sendButton = $('#send_picture .extensionsMenuExtensionButton');
@ -23,6 +63,9 @@ async function setImageIcon() {
}
}
/**
* Sets a spinner icon for the send button.
*/
async function setSpinnerIcon() {
try {
const sendButton = $('#send_picture .extensionsMenuExtensionButton');
@ -34,9 +77,21 @@ async function setSpinnerIcon() {
}
}
/**
* Sends a captioned message to the chat.
* @param {string} caption Caption text
* @param {string} image Image URL
*/
async function sendCaptionedMessage(caption, image) {
const context = getContext();
let messageText = `[${context.name1} sends ${context.name2 ?? ''} a picture that contains: ${caption}]`;
let template = extension_settings.caption.template || TEMPLATE_DEFAULT;
if (!/{{caption}}/i.test(template)) {
console.warn('Poka-yoke: Caption template does not contain {{caption}}. Appending it.')
template += ' {{caption}}';
}
let messageText = substituteParams(template).replace(/{{caption}}/i, caption);
if (extension_settings.caption.refine_mode) {
messageText = await callPopup(
@ -62,45 +117,108 @@ async function sendCaptionedMessage(caption, image) {
};
context.chat.push(message);
context.addOneMessage(message);
await context.generate('caption');
}
async function doCaptionRequest(base64Img) {
if (extension_settings.caption.local) {
const apiResult = await fetch('/api/extra/caption', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ image: base64Img })
});
/**
* Generates a caption for an image using a selected source.
* @param {string} base64Img Base64 encoded image without the data:image/...;base64, prefix
* @param {string} fileData Base64 encoded image with the data:image/...;base64, prefix
* @returns {Promise<{caption: string}>} Generated caption
*/
async function doCaptionRequest(base64Img, fileData) {
switch (extension_settings.caption.source) {
case 'local':
return await captionLocal(base64Img);
case 'extras':
return await captionExtras(base64Img);
case 'horde':
return await captionHorde(base64Img);
case 'multimodal':
return await captionMultimodal(fileData);
default:
throw new Error('Unknown caption source.');
}
}
if (!apiResult.ok) {
throw new Error('Failed to caption image via local pipeline.');
}
const data = await apiResult.json();
return data;
} else if (modules.includes('caption')) {
const url = new URL(getApiUrl());
url.pathname = '/api/caption';
const apiResult = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass',
},
body: JSON.stringify({ image: base64Img })
});
if (!apiResult.ok) {
throw new Error('Failed to caption image via Extras.');
}
const data = await apiResult.json();
return data;
} else {
/**
* Generates a caption for an image using Extras API.
* @param {string} base64Img Base64 encoded image without the data:image/...;base64, prefix
* @returns {Promise<{caption: string}>} Generated caption
*/
async function captionExtras(base64Img) {
if (!modules.includes('caption')) {
throw new Error('No captioning module is available.');
}
const url = new URL(getApiUrl());
url.pathname = '/api/caption';
const apiResult = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass',
},
body: JSON.stringify({ image: base64Img })
});
if (!apiResult.ok) {
throw new Error('Failed to caption image via Extras.');
}
const data = await apiResult.json();
return data;
}
/**
* Generates a caption for an image using a local model.
* @param {string} base64Img Base64 encoded image without the data:image/...;base64, prefix
* @returns {Promise<{caption: string}>} Generated caption
*/
async function captionLocal(base64Img) {
const apiResult = await fetch('/api/extra/caption', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ image: base64Img })
});
if (!apiResult.ok) {
throw new Error('Failed to caption image via local pipeline.');
}
const data = await apiResult.json();
return data;
}
/**
* Generates a caption for an image using a Horde model.
* @param {string} base64Img Base64 encoded image without the data:image/...;base64, prefix
* @returns {Promise<{caption: string}>} Generated caption
*/
async function captionHorde(base64Img) {
const apiResult = await fetch('/api/horde/caption-image', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ image: base64Img })
});
if (!apiResult.ok) {
throw new Error('Failed to caption image via Horde.');
}
const data = await apiResult.json();
return data;
}
/**
* Generates a caption for an image using a multimodal model.
* @param {string} base64Img Base64 encoded image with the data:image/...;base64, prefix
* @returns {Promise<{caption: string}>} Generated caption
*/
async function captionMultimodal(base64Img) {
const prompt = extension_settings.caption.prompt || PROMPT_DEFAULT;
const caption = await getMultimodalCaption(base64Img, prompt);
return { caption };
}
async function onSelectImage(e) {
@ -116,11 +234,8 @@ async function onSelectImage(e) {
const fileData = await getBase64Async(file);
const base64Format = fileData.split(',')[0].split(';')[0].split('/')[1];
const base64Data = fileData.split(',')[1];
const data = await doCaptionRequest(base64Data);
const caption = data.caption;
const imageToSave = data.thumbnail ? data.thumbnail : base64Data;
const format = data.thumbnail ? 'jpeg' : base64Format;
const imagePath = await saveBase64AsFile(imageToSave, context.name2, '', format);
const { caption } = await doCaptionRequest(base64Data, fileData);
const imagePath = await saveBase64AsFile(base64Data, context.name2, '', base64Format);
await sendCaptionedMessage(caption, imagePath);
}
catch (error) {
@ -143,16 +258,26 @@ jQuery(function () {
const sendButton = $(`
<div id="send_picture" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-image extensionsMenuExtensionButton"></div>
Send a Picture
Generate Caption
</div>`);
const attachFileButton = $(`
<div id="attachFile" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-paperclip extensionsMenuExtensionButton"></div>
Attach a File
</div>`);
$('#extensionsMenu').prepend(sendButton);
$(sendButton).hide();
$('#extensionsMenu').prepend(attachFileButton);
$(sendButton).on('click', () => {
const hasCaptionModule = modules.includes('caption') || extension_settings.caption.local;
const hasCaptionModule =
(modules.includes('caption') && extension_settings.caption.source === 'extras') ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'openai' && secret_state[SECRET_KEYS.OPENAI]) ||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'openrouter' && secret_state[SECRET_KEYS.OPENROUTER]) ||
extension_settings.caption.source === 'local' ||
extension_settings.caption.source === 'horde';
if (!hasCaptionModule) {
toastr.error('No captioning module is available. Either enable the local captioning pipeline or connect to Extras.');
toastr.error('Choose other captioning source in the extension settings.', 'Captioning is not available');
return;
}
@ -160,7 +285,7 @@ jQuery(function () {
});
}
function addPictureSendForm() {
const inputHtml = `<input id="img_file" type="file" accept="image/*">`;
const inputHtml = `<input id="img_file" type="file" hidden accept="image/*">`;
const imgForm = document.createElement('form');
imgForm.id = 'img_form';
$(imgForm).append(inputHtml);
@ -168,6 +293,29 @@ jQuery(function () {
$('#form_sheld').append(imgForm);
$('#img_file').on('change', onSelectImage);
}
function switchMultimodalBlocks() {
const isMultimodal = extension_settings.caption.source === 'multimodal';
$('#caption_multimodal_block').toggle(isMultimodal);
$('#caption_prompt_block').toggle(isMultimodal);
$('#caption_multimodal_api').val(extension_settings.caption.multimodal_api);
$('#caption_multimodal_model').val(extension_settings.caption.multimodal_model);
$('#caption_multimodal_model option').each(function () {
const type = $(this).data('type');
$(this).toggle(type === extension_settings.caption.multimodal_api);
});
$('#caption_multimodal_api').on('change', () => {
const api = String($('#caption_multimodal_api').val());
const model = String($(`#caption_multimodal_model option[data-type="${api}"]`).first().val());
extension_settings.caption.multimodal_api = api;
extension_settings.caption.multimodal_model = model;
saveSettingsDebounced();
switchMultimodalBlocks();
});
$('#caption_multimodal_model').on('change', () => {
extension_settings.caption.multimodal_model = String($('#caption_multimodal_model').val());
saveSettingsDebounced();
});
}
function addSettings() {
const html = `
<div class="caption_settings">
@ -177,13 +325,39 @@ jQuery(function () {
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<label class="checkbox_label" for="caption_local">
<input id="caption_local" type="checkbox" class="checkbox">
Use local captioning pipeline
</label>
<label class="checkbox_label" for="caption_refine_mode">
<label for="caption_source">Source</label>
<select id="caption_source" class="text_pole">
<option value="local">Local</option>
<option value="multimodal">Multimodal (OpenAI / OpenRouter)</option>
<option value="extras">Extras</option>
<option value="horde">Horde</option>
</select>
<div id="caption_multimodal_block" class="flex-container wide100p">
<div class="flex1 flex-container flexFlowColumn flexNoGap">
<label for="caption_multimodal_api">API</label>
<select id="caption_multimodal_api" class="flex1 text_pole">
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
</select>
</div>
<div class="flex1 flex-container flexFlowColumn flexNoGap">
<label for="caption_multimodal_model">Model</label>
<select id="caption_multimodal_model" class="flex1 text_pole">
<option data-type="openai" value="gpt-4-vision-preview">gpt-4-vision-preview</option>
<option data-type="openrouter" value="openai/gpt-4-vision-preview">openai/gpt-4-vision-preview</option>
<option data-type="openrouter" value="haotian-liu/llava-13b">haotian-liu/llava-13b</option>
</select>
</div>
</div>
<div id="caption_prompt_block">
<label for="caption_prompt">Caption Prompt</label>
<textarea id="caption_prompt" class="text_pole" rows="1" placeholder="&lt; Use default &gt;">${PROMPT_DEFAULT}</textarea>
</div>
<label for="caption_template">Message Template <small>(use <code>{{caption}}</code> macro)</small></label>
<textarea id="caption_template" class="text_pole" rows="2" placeholder="&lt; Use default &gt;">${TEMPLATE_DEFAULT}</textarea>
<label class="checkbox_label margin-bot-10px" for="caption_refine_mode">
<input id="caption_refine_mode" type="checkbox" class="checkbox">
Edit captions before generation
Edit captions before saving
</label>
</div>
</div>
@ -196,13 +370,25 @@ jQuery(function () {
addPictureSendForm();
addSendPictureButton();
setImageIcon();
moduleWorker();
migrateSettings();
switchMultimodalBlocks();
$('#caption_refine_mode').prop('checked', !!(extension_settings.caption.refine_mode));
$('#caption_local').prop('checked', !!(extension_settings.caption.local));
$('#caption_source').val(extension_settings.caption.source);
$('#caption_prompt').val(extension_settings.caption.prompt);
$('#caption_template').val(extension_settings.caption.template);
$('#caption_refine_mode').on('input', onRefineModeInput);
$('#caption_local').on('input', () => {
extension_settings.caption.local = !!$('#caption_local').prop('checked');
$('#caption_source').on('change', () => {
extension_settings.caption.source = String($('#caption_source').val());
switchMultimodalBlocks();
saveSettingsDebounced();
});
$('#caption_prompt').on('input', () => {
extension_settings.caption.prompt = String($('#caption_prompt').val());
saveSettingsDebounced();
});
$('#caption_template').on('input', () => {
extension_settings.caption.template = String($('#caption_template').val());
saveSettingsDebounced();
});
setInterval(moduleWorker, UPDATE_INTERVAL);
});

View File

@ -4,6 +4,7 @@ import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper
import { loadMovingUIState, power_user } from "../../power-user.js";
import { registerSlashCommand } from "../../slash-commands.js";
import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence } from "../../utils.js";
import { hideMutedSprites } from "../../group-chats.js";
export { MODULE_NAME };
const MODULE_NAME = 'expressions';
@ -118,7 +119,7 @@ async function visualNovelSetCharacterSprites(container, name, expression) {
const isDisabled = group.disabled_members.includes(avatar);
// skip disabled characters
if (isDisabled) {
if (isDisabled && hideMutedSprites) {
continue;
}
@ -208,7 +209,7 @@ async function visualNovelUpdateLayers(container) {
const containerWidth = container.width();
const pivotalPoint = containerWidth * 0.5;
let images = $('.expression-holder');
let images = $('#visual-novel-wrapper .expression-holder');
let imagesWidth = [];
images.sort(sortFunction).each(function () {
@ -1475,22 +1476,19 @@ function setExpressionOverrideHtml(forceClear = false) {
dragElement($("#expression-holder"))
eventSource.on(event_types.CHAT_CHANGED, () => {
// character changed
const context = getContext();
if (context.groupId !== lastCharacter && context.characterId !== lastCharacter) {
removeExpression();
spriteCache = {};
removeExpression();
spriteCache = {};
//clear expression
let imgElement = document.getElementById('expression-image');
if (imgElement && imgElement instanceof HTMLImageElement) {
imgElement.src = "";
}
//clear expression
let imgElement = document.getElementById('expression-image');
if (imgElement && imgElement instanceof HTMLImageElement) {
imgElement.src = "";
}
//set checkbox to global var
$('#image_type_toggle').prop('checked', extension_settings.expressions.talkinghead);
if (extension_settings.expressions.talkinghead) {
setTalkingHeadState(extension_settings.expressions.talkinghead);
}
//set checkbox to global var
$('#image_type_toggle').prop('checked', extension_settings.expressions.talkinghead);
if (extension_settings.expressions.talkinghead) {
setTalkingHeadState(extension_settings.expressions.talkinghead);
}
setExpressionOverrideHtml();

View File

@ -190,8 +190,3 @@ img.expression.default {
flex-direction: row;
}
@media screen and (max-width:1200px) {
div.expression {
display: none;
}
}

View File

@ -651,14 +651,12 @@ jQuery(function () {
<option value="main">Main API</option>
<option value="extras">Extras API</option>
</select><br>
<div class="flex-container justifyspacebetween alignitemscenter">
<span class="flex1">Current summary:</span>
<div id="memory_restore" class="menu_button flex1 margin0"><span>Restore Previous</span></div>
</div>
<textarea id="memory_contents" class="text_pole textarea_compact" rows="6" placeholder="Summary will be generated here..."></textarea>
<div class="memory_contents_controls">
<div id="memory_force_summarize" class="menu_button menu_button_icon">
@ -669,14 +667,17 @@ jQuery(function () {
<label for="memory_skipWIAN"><input id="memory_skipWIAN" type="checkbox" />No WI/AN</label>
</div>
<div class="memory_contents_controls">
<div id="summarySettingsBlockToggle" class="menu_button">Settings</div>
<div id="summarySettingsBlockToggle" class="menu_button menu_button_icon" title="Edit summarization prompt, insertion position, etc.">
<i class="fa-solid fa-cog"></i>
<span>Summary Settings</span>
</div>
</div>
<div id="summarySettingsBlock" style="display:none;">
<div id="summarySettingsBlock" style="display:none;">
<div class="memory_template">
<label for="memory_template">Insertion string:</label>
<label for="memory_template">Insertion Template</label>
<textarea id="memory_template" class="text_pole textarea_compact" rows="2" placeholder="{{summary}} will resolve to the current summary contents."></textarea>
</div>
<label for="memory_position">Position:</label>
<label for="memory_position">Injection Position</label>
<div class="radio_group">
<label>
<input type="radio" name="memory_position" value="2" />

View File

@ -0,0 +1,44 @@
<div id="quickReply_contextMenuEditor_template">
<div class="quickReply_contextMenuEditor">
<h3><strong>Context Menu Editor</strong></h3>
<div id="quickReply_contextMenuEditor_content">
<template id="quickReply_contextMenuEditor_itemTemplate">
<div class="quickReplyContextMenuEditor_item flex-container alignitemscenter" data-order="0">
<span class="drag-handle ui-sortable-handle"></span>
<select class="quickReply_contextMenuEditor_preset"></select>
<label class="flex-container" title="When enabled, the current Quick Reply will be sent together with (before) the clicked QR from the context menu.">
Chaining:
<input type="checkbox" class="quickReply_contextMenuEditor_chaining">
</label>
<span class="quickReply_contextMenuEditor_remove menu_button menu_button_icon fa-solid fa-trash-can" title="Remove entry"></span>
</div>
</template>
</div>
<div class="quickReply_contextMenuEditor_actions">
<span id="quickReply_contextMenuEditor_addPreset" class="menu_button menu_button_icon fa-solid fa-plus" title="Add preset to context menu"></span>
</div>
<h3><strong>Auto-Execute</strong></h3>
<div class="flex-container flexFlowColumn">
<label class="checkbox_label" for="quickReply_hidden">
<input type="checkbox" id="quickReply_hidden" >
<span><i class="fa-solid fa-fw fa-eye-slash"></i> Invisible (auto-execute only)</span>
</label>
<label class="checkbox_label" for="quickReply_autoExecute_appStartup">
<input type="checkbox" id="quickReply_autoExecute_appStartup" >
<span><i class="fa-solid fa-fw fa-rocket"></i> Execute on app startup</span>
</label>
<label class="checkbox_label" for="quickReply_autoExecute_userMessage">
<input type="checkbox" id="quickReply_autoExecute_userMessage" >
<span><i class="fa-solid fa-fw fa-user"></i> Execute on user message</span>
</label>
<label class="checkbox_label" for="quickReply_autoExecute_botMessage">
<input type="checkbox" id="quickReply_autoExecute_botMessage" >
<span><i class="fa-solid fa-fw fa-robot"></i> Execute on AI message</span>
</label>
<label class="checkbox_label" for="quickReply_autoExecute_chatLoad">
<input type="checkbox" id="quickReply_autoExecute_chatLoad" >
<span><i class="fa-solid fa-fw fa-message"></i> Execute on opening chat</span>
</label>
</div>
</div>
</div>

View File

@ -1,7 +1,12 @@
import { saveSettingsDebounced, callPopup, getRequestHeaders, substituteParams } from "../../../script.js";
import { saveSettingsDebounced, callPopup, getRequestHeaders, substituteParams, eventSource, event_types } from "../../../script.js";
import { getContext, extension_settings } from "../../extensions.js";
import { initScrollHeight, resetScrollHeight } from "../../utils.js";
import { registerSlashCommand } from "../../slash-commands.js";
import { initScrollHeight, resetScrollHeight, getSortableDelay } from "../../utils.js";
import { executeSlashCommands, registerSlashCommand } from "../../slash-commands.js";
import { ContextMenu } from "./src/ContextMenu.js";
import { MenuItem } from "./src/MenuItem.js";
import { MenuHeader } from "./src/MenuHeader.js";
import { loadMovingUIState } from "../../power-user.js";
import { dragElement } from "../../RossAscends-mods.js";
export { MODULE_NAME };
@ -21,7 +26,7 @@ const defaultSettings = {
//method from worldinfo
async function updateQuickReplyPresetList() {
var result = await fetch("/getsettings", {
const result = await fetch("/getsettings", {
method: "POST",
headers: getRequestHeaders(),
body: JSON.stringify({}),
@ -99,10 +104,105 @@ function onQuickReplyInput(id) {
function onQuickReplyLabelInput(id) {
extension_settings.quickReply.quickReplySlots[id - 1].label = $(`#quickReply${id}Label`).val();
$(`#quickReply${id}`).text(String($(`#quickReply${id}Label`).val()));
addQuickReplyBar();
saveSettingsDebounced();
}
async function onQuickReplyContextMenuChange(id) {
extension_settings.quickReply.quickReplySlots[id - 1].contextMenu = JSON.parse($(`#quickReplyContainer > [data-order="${id}"]`).attr('data-contextMenu'))
saveSettingsDebounced();
}
async function onQuickReplyCtxButtonClick(id) {
const editorHtml = $(await $.get('scripts/extensions/quick-reply/contextMenuEditor.html'));
const popupResult = callPopup(editorHtml, "confirm", undefined, { okButton: "Save", wide: false, large: false, rows: 1 });
const qr = extension_settings.quickReply.quickReplySlots[id - 1];
if (!qr.contextMenu) {
qr.contextMenu = [];
}
/**@type {HTMLTemplateElement}*/
const tpl = document.querySelector('#quickReply_contextMenuEditor_itemTemplate');
const fillPresetSelect = (select, item) => {
[{ name: 'Select a preset', value: '' }, ...presets].forEach(preset => {
const opt = document.createElement('option'); {
opt.value = preset.value ?? preset.name;
opt.textContent = preset.name;
opt.selected = preset.name == item.preset;
select.append(opt);
}
});
};
const addCtxItem = (item, idx) => {
const dom = tpl.content.cloneNode(true);
const ctxItem = dom.querySelector('.quickReplyContextMenuEditor_item');
ctxItem.setAttribute('data-order', idx);
const select = ctxItem.querySelector('.quickReply_contextMenuEditor_preset');
fillPresetSelect(select, item);
dom.querySelector('.quickReply_contextMenuEditor_chaining').checked = item.chain;
$('.quickReply_contextMenuEditor_remove', ctxItem).on('click', () => ctxItem.remove());
document.querySelector('#quickReply_contextMenuEditor_content').append(ctxItem);
}
[...qr.contextMenu, {}].forEach((item, idx) => {
addCtxItem(item, idx)
});
$('#quickReply_contextMenuEditor_addPreset').on('click', () => {
addCtxItem({}, document.querySelector('#quickReply_contextMenuEditor_content').children.length);
});
$('#quickReply_contextMenuEditor_content').sortable({
delay: getSortableDelay(),
stop: () => { },
});
$('#quickReply_autoExecute_userMessage').prop('checked', qr.autoExecute_userMessage ?? false);
$('#quickReply_autoExecute_botMessage').prop('checked', qr.autoExecute_botMessage ?? false);
$('#quickReply_autoExecute_chatLoad').prop('checked', qr.autoExecute_chatLoad ?? false);
$('#quickReply_autoExecute_appStartup').prop('checked', qr.autoExecute_appStartup ?? false);
$('#quickReply_hidden').prop('checked', qr.hidden ?? false);
$('#quickReply_hidden').on('input', () => {
const state = !!$('#quickReply_hidden').prop('checked');
qr.hidden = state;
saveSettingsDebounced();
});
$('#quickReply_autoExecute_appStartup').on('input', () => {
const state = !!$('#quickReply_autoExecute_appStartup').prop('checked');
qr.autoExecute_appStartup = state;
saveSettingsDebounced();
});
$('#quickReply_autoExecute_userMessage').on('input', () => {
const state = !!$('#quickReply_autoExecute_userMessage').prop('checked');
qr.autoExecute_userMessage = state;
saveSettingsDebounced();
});
$('#quickReply_autoExecute_botMessage').on('input', () => {
const state = !!$('#quickReply_autoExecute_botMessage').prop('checked');
qr.autoExecute_botMessage = state;
saveSettingsDebounced();
});
$('#quickReply_autoExecute_chatLoad').on('input', () => {
const state = !!$('#quickReply_autoExecute_chatLoad').prop('checked');
qr.autoExecute_chatLoad = state;
saveSettingsDebounced();
});
if (await popupResult) {
qr.contextMenu = Array.from(document.querySelectorAll('#quickReply_contextMenuEditor_content > .quickReplyContextMenuEditor_item'))
.map(item => ({
preset: item.querySelector('.quickReply_contextMenuEditor_preset').value,
chain: item.querySelector('.quickReply_contextMenuEditor_chaining').checked,
}))
.filter(item => item.preset);
$(`#quickReplyContainer[data-order="${id}"]`).attr('data-contextMenu', JSON.stringify(qr.contextMenu));
updateQuickReplyPreset();
onQuickReplyLabelInput(id);
}
}
async function onQuickReplyEnabledInput() {
let isEnabled = $(this).prop('checked')
extension_settings.quickReply.quickReplyEnabled = !!isEnabled;
@ -129,13 +229,32 @@ async function onAutoInputInject() {
}
async function sendQuickReply(index) {
const existingText = $("#send_textarea").val();
const prompt = extension_settings.quickReply.quickReplySlots[index]?.mes || '';
return await performQuickReply(prompt, index);
}
async function executeQuickReplyByName(name) {
if (!extension_settings.quickReply.quickReplyEnabled) {
throw new Error('Quick Reply is disabled');
}
const qr = extension_settings.quickReply.quickReplySlots.find(x => x.label == name);
if (!qr) {
throw new Error(`Quick Reply "${name}" not found`);
}
return await performQuickReply(qr.mes);
}
window['executeQuickReplyByName'] = executeQuickReplyByName;
async function performQuickReply(prompt, index) {
if (!prompt) {
console.warn(`Quick reply slot ${index} is empty! Aborting.`);
return;
}
const existingText = $("#send_textarea").val();
let newText;
@ -150,6 +269,12 @@ async function sendQuickReply(index) {
newText = `${prompt} `;
}
// the prompt starts with '/' - execute slash commands natively
if (prompt.startsWith('/')) {
const result = await executeSlashCommands(newText);
return result?.pipe;
}
newText = substituteParams(newText);
$("#send_textarea").val(newText);
@ -158,21 +283,161 @@ async function sendQuickReply(index) {
$("#send_textarea").trigger('focus');
// Only trigger send button if quickActionEnabled is not checked or
// the prompt starts with '/'
if (!extension_settings.quickReply.quickActionEnabled || prompt.startsWith('/')) {
if (!extension_settings.quickReply.quickActionEnabled) {
$("#send_but").trigger('click');
}
}
function buildContextMenu(qr, chainMes = null, hierarchy = [], labelHierarchy = []) {
const tree = {
label: qr.label,
mes: (chainMes && qr.mes ? `${chainMes} | ` : '') + qr.mes,
children: [],
};
qr.contextMenu?.forEach(ctxItem => {
let chain = ctxItem.chain;
let subName = ctxItem.preset;
const sub = presets.find(it => it.name == subName);
if (sub) {
// prevent circular references
if (hierarchy.indexOf(sub.name) == -1) {
const nextHierarchy = [...hierarchy, sub.name];
const nextLabelHierarchy = [...labelHierarchy, tree.label];
tree.children.push(new MenuHeader(sub.name));
sub.quickReplySlots.forEach(subQr => {
const subInfo = buildContextMenu(subQr, chain ? tree.mes : null, nextHierarchy, nextLabelHierarchy);
tree.children.push(new MenuItem(
subInfo.label,
subInfo.mes,
(evt) => {
evt.stopPropagation();
performQuickReply(subInfo.mes.replace(/%%parent(-\d+)?%%/g, (_, index) => {
return nextLabelHierarchy.slice(parseInt(index ?? '-1'))[0];
}));
},
subInfo.children,
));
});
}
}
});
return tree;
}
async function doQuickReplyBarPopout() {
//shared elements
const newQuickRepliesDiv = `<div id="quickReplies"></div>`
const popoutButtonClone = $("#quickReplyPopoutButton")
if ($("#quickReplyBarPopout").length === 0) {
console.debug('did not see popout yet, creating')
const template = $('#zoomed_avatar_template').html();
const controlBarHtml = `<div class="panelControlBar flex-container">
<div id="quickReplyBarPopoutheader" class="fa-solid fa-grip drag-grabber hoverglow"></div>
<div id="quickReplyBarPopoutClose" class="fa-solid fa-circle-xmark hoverglow"></div>
</div>`
const newElement = $(template);
let quickRepliesClone = $('#quickReplies').html()
newElement.attr('id', 'quickReplyBarPopout')
.removeClass('zoomed_avatar')
.addClass('draggable scrollY')
.empty()
.append(controlBarHtml)
.append(newQuickRepliesDiv)
//empty original bar
$("#quickReplyBar").empty()
//add clone in popout
$('body').append(newElement);
$("#quickReplies").append(quickRepliesClone).css('margin-top', '1em')
$('.quickReplyButton').on('click', function () {
let index = $(this).data('index');
sendQuickReply(index);
});
$('.quickReplyButton > .ctx-expander').on('click', function (evt) {
evt.stopPropagation();
let index = $(this.closest('.quickReplyButton')).data('index');
const qr = extension_settings.quickReply.quickReplySlots[index];
if (qr.contextMenu?.length) {
evt.preventDefault();
const tree = buildContextMenu(qr);
const menu = new ContextMenu(tree.children);
menu.show(evt);
}
})
$('.quickReplyButton').on('contextmenu', function (evt) {
let index = $(this).data('index');
const qr = extension_settings.quickReply.quickReplySlots[index];
if (qr.contextMenu?.length) {
evt.preventDefault();
const tree = buildContextMenu(qr);
const menu = new ContextMenu(tree.children);
menu.show(evt);
}
});
loadMovingUIState();
$("#quickReplyBarPopout").fadeIn(250)
dragElement(newElement)
$('#quickReplyBarPopoutClose').off('click').on('click', function () {
console.debug('saw existing popout, removing')
let quickRepliesClone = $('#quickReplies').html()
$("#quickReplyBar").append(newQuickRepliesDiv)
$("#quickReplies").prepend(quickRepliesClone)
$("#quickReplyBar").append(popoutButtonClone).fadeIn(250)
$("#quickReplyBarPopout").fadeOut(250, () => { $("#quickReplyBarPopout").remove() });
$('.quickReplyButton').on('click', function () {
let index = $(this).data('index');
sendQuickReply(index);
});
$('.quickReplyButton > .ctx-expander').on('click', function (evt) {
evt.stopPropagation();
let index = $(this.closest('.quickReplyButton')).data('index');
const qr = extension_settings.quickReply.quickReplySlots[index];
if (qr.contextMenu?.length) {
evt.preventDefault();
const tree = buildContextMenu(qr);
const menu = new ContextMenu(tree.children);
menu.show(evt);
}
})
$('.quickReplyButton').on('contextmenu', function (evt) {
let index = $(this).data('index');
const qr = extension_settings.quickReply.quickReplySlots[index];
if (qr.contextMenu?.length) {
evt.preventDefault();
const tree = buildContextMenu(qr);
const menu = new ContextMenu(tree.children);
menu.show(evt);
}
});
$("#quickReplyPopoutButton").off('click').on('click', doQuickReplyBarPopout)
})
}
}
function addQuickReplyBar() {
$('#quickReplyBar').remove();
let quickReplyButtonHtml = '';
var targetContainer;
if ($("#quickReplyBarPopout").length !== 0) {
targetContainer = 'popout'
} else {
targetContainer = 'bar'
$("#quickReplyBar").remove();
}
for (let i = 0; i < extension_settings.quickReply.numberOfSlots; i++) {
let quickReplyMes = extension_settings.quickReply.quickReplySlots[i]?.mes || '';
let quickReplyLabel = extension_settings.quickReply.quickReplySlots[i]?.label || '';
quickReplyButtonHtml += `<div title="${quickReplyMes}" class="quickReplyButton" data-index="${i}" id="quickReply${i + 1}">${quickReplyLabel}</div>`;
const qr = extension_settings.quickReply.quickReplySlots[i];
const quickReplyMes = qr?.mes || '';
const quickReplyLabel = qr?.label || '';
const hidden = qr?.hidden ?? false;
let expander = '';
if (extension_settings.quickReply.quickReplySlots[i]?.contextMenu?.length) {
expander = '<span class="ctx-expander" title="Open context menu">⋮</span>';
}
quickReplyButtonHtml += `<div title="${quickReplyMes}" class="quickReplyButton ${hidden ? 'displayNone' : ''}" data-index="${i}" id="quickReply${i + 1}">${quickReplyLabel}${expander}</div>`;
}
const quickReplyBarFullHtml = `
@ -180,15 +445,43 @@ function addQuickReplyBar() {
<div id="quickReplies">
${quickReplyButtonHtml}
</div>
<div id="quickReplyPopoutButton" class="fa-solid fa-window-restore menu_button"></div>
</div>
`;
console.log(targetContainer)
if (targetContainer === 'bar') {
$('#send_form').prepend(quickReplyBarFullHtml);
} else {
$("#quickReplies").empty().append(quickReplyButtonHtml)
}
$('#send_form').prepend(quickReplyBarFullHtml);
$('.quickReplyButton').on('click', function () {
let index = $(this).data('index');
sendQuickReply(index);
});
$("#quickReplyPopoutButton").off('click').on('click', doQuickReplyBarPopout)
$('.quickReplyButton > .ctx-expander').on('click', function (evt) {
evt.stopPropagation();
let index = $(this.closest('.quickReplyButton')).data('index');
const qr = extension_settings.quickReply.quickReplySlots[index];
if (qr.contextMenu?.length) {
evt.preventDefault();
const tree = buildContextMenu(qr);
const menu = new ContextMenu(tree.children);
menu.show(evt);
}
})
$('.quickReplyButton').on('contextmenu', function (evt) {
let index = $(this).data('index');
const qr = extension_settings.quickReply.quickReplySlots[index];
if (qr.contextMenu?.length) {
evt.preventDefault();
const tree = buildContextMenu(qr);
const menu = new ContextMenu(tree.children);
menu.show(evt);
}
});
}
async function moduleWorker() {
@ -212,7 +505,52 @@ async function saveQuickReplyPreset() {
quickReplyEnabled: extension_settings.quickReply.quickReplyEnabled,
quickReplySlots: extension_settings.quickReply.quickReplySlots,
numberOfSlots: extension_settings.quickReply.numberOfSlots,
selectedPreset: name
AutoInputInject: extension_settings.quickReply.AutoInputInject,
selectedPreset: name,
}
const response = await fetch('/savequickreply', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(quickReplyPreset)
});
if (response.ok) {
const quickReplyPresetIndex = presets.findIndex(x => x.name == name);
if (quickReplyPresetIndex == -1) {
presets.push(quickReplyPreset);
const option = document.createElement('option');
option.selected = true;
option.value = name;
option.innerText = name;
$('#quickReplyPresets').append(option);
}
else {
presets[quickReplyPresetIndex] = quickReplyPreset;
$(`#quickReplyPresets option[value="${name}"]`).prop('selected', true);
}
saveSettingsDebounced();
} else {
toastr.warning('Failed to save Quick Reply Preset.')
}
}
//just a copy of save function with the name hardcoded to currently selected preset
async function updateQuickReplyPreset() {
const name = $("#quickReplyPresets").val()
if (!name) {
return;
}
const quickReplyPreset = {
name: name,
quickReplyEnabled: extension_settings.quickReply.quickReplyEnabled,
quickReplySlots: extension_settings.quickReply.quickReplySlots,
numberOfSlots: extension_settings.quickReply.numberOfSlots,
AutoInputInject: extension_settings.quickReply.AutoInputInject,
selectedPreset: name,
}
const response = await fetch('/savequickreply', {
@ -285,10 +623,13 @@ function generateQuickReplyElements() {
let quickReplyHtml = '';
for (let i = 1; i <= extension_settings.quickReply.numberOfSlots; i++) {
let itemNumber = i + 1
quickReplyHtml += `
<div class="flex-container alignitemsflexstart">
<div class="flex-container alignitemscenter" data-order="${i}">
<span class="drag-handle ui-sortable-handle">☰</span>
<input class="text_pole wide30p" id="quickReply${i}Label" placeholder="(Button label)">
<textarea id="quickReply${i}Mes" placeholder="(Custom message or /command)" class="text_pole widthUnset flex1" rows="2"></textarea>
<span class="menu_button menu_button_icon" id="quickReply${i}CtxButton" title="Additional options: context menu, auto-execution"></span>
<textarea id="quickReply${i}Mes" placeholder="(Custom message or /command)" class="text_pole widthUnset flex1 autoSetHeight" rows="2"></textarea>
</div>
`;
}
@ -298,6 +639,8 @@ function generateQuickReplyElements() {
for (let i = 1; i <= extension_settings.quickReply.numberOfSlots; i++) {
$(`#quickReply${i}Mes`).on('input', function () { onQuickReplyInput(i); });
$(`#quickReply${i}Label`).on('input', function () { onQuickReplyLabelInput(i); });
$(`#quickReply${i}CtxButton`).on('click', function () { onQuickReplyCtxButtonClick(i); });
$(`#quickReplyContainer > [data-order="${i}"]`).attr('data-contextMenu', JSON.stringify(extension_settings.quickReply.quickReplySlots[i - 1]?.contextMenu ?? []));
}
$('.quickReplySettings .inline-drawer-toggle').off('click').on('click', function () {
@ -346,6 +689,120 @@ async function doQR(_, text) {
whichQR.trigger('click')
}
function saveQROrder() {
//update html-level order data to match new sort
let i = 1
$('#quickReplyContainer').children().each(function () {
$(this).attr('data-order', i)
$(this).find('input').attr('id', `quickReply${i}Label`)
$(this).find('textarea').attr('id', `quickReply${i}Mes`)
i++
});
//rebuild the extension_Settings array based on new order
i = 1
$('#quickReplyContainer').children().each(function () {
onQuickReplyContextMenuChange(i)
onQuickReplyLabelInput(i)
onQuickReplyInput(i)
i++
});
}
let onMessageSentExecuting = false;
let onMessageReceivedExecuting = false;
let onChatChangedExecuting = false;
/**
* Executes quick replies on message received.
* @param {number} index New message index
* @returns {Promise<void>}
*/
async function onMessageReceived(index) {
if (!extension_settings.quickReply.quickReplyEnabled) return;
if (onMessageReceivedExecuting) return;
try {
onMessageReceivedExecuting = true;
for (let i = 0; i < extension_settings.quickReply.numberOfSlots; i++) {
const qr = extension_settings.quickReply.quickReplySlots[i];
if (qr?.autoExecute_botMessage) {
const message = getContext().chat[index];
if (message?.mes && message?.mes !== '...') {
await sendQuickReply(i);
}
}
}
} finally {
onMessageReceivedExecuting = false;
}
}
/**
* Executes quick replies on message sent.
* @param {number} index New message index
* @returns {Promise<void>}
*/
async function onMessageSent(index) {
if (!extension_settings.quickReply.quickReplyEnabled) return;
if (onMessageSentExecuting) return;
try {
onMessageSentExecuting = true;
for (let i = 0; i < extension_settings.quickReply.numberOfSlots; i++) {
const qr = extension_settings.quickReply.quickReplySlots[i];
if (qr?.autoExecute_userMessage) {
const message = getContext().chat[index];
if (message?.mes && message?.mes !== '...') {
await sendQuickReply(i);
}
}
}
} finally {
onMessageSentExecuting = false;
}
}
/**
* Executes quick replies on chat changed.
* @param {string} chatId New chat id
* @returns {Promise<void>}
*/
async function onChatChanged(chatId) {
if (!extension_settings.quickReply.quickReplyEnabled) return;
if (onChatChangedExecuting) return;
try {
onChatChangedExecuting = true;
for (let i = 0; i < extension_settings.quickReply.numberOfSlots; i++) {
const qr = extension_settings.quickReply.quickReplySlots[i];
if (qr?.autoExecute_chatLoad && chatId) {
await sendQuickReply(i);
}
}
} finally {
onChatChangedExecuting = false;
}
}
/**
* Executes quick replies on app ready.
* @returns {Promise<void>}
*/
async function onAppReady() {
if (!extension_settings.quickReply.quickReplyEnabled) return;
for (let i = 0; i < extension_settings.quickReply.numberOfSlots; i++) {
const qr = extension_settings.quickReply.quickReplySlots[i];
if (qr?.autoExecute_appStartup) {
await sendQuickReply(i);
}
}
}
jQuery(async () => {
moduleWorker();
setInterval(moduleWorker, UPDATE_INTERVAL);
@ -380,7 +837,10 @@ jQuery(async () => {
</select>
<div id="quickReplyPresetSaveButton" class="menu_button menu_button_icon">
<div class="fa-solid fa-save"></div>
<span>Save</span>
<span>Save New</span>
</div>
<div id="quickReplyPresetUpdateButton" class="menu_button menu_button_icon">
<span>Update</span>
</div>
</div>
<label for="quickReplyNumberOfSlots">Number of slots:</label>
@ -407,6 +867,12 @@ jQuery(async () => {
$('#quickReplyEnabled').on('input', onQuickReplyEnabledInput);
$('#quickReplyNumberOfSlotsApply').on('click', onQuickReplyNumberOfSlotsInput);
$("#quickReplyPresetSaveButton").on('click', saveQuickReplyPreset);
$("#quickReplyPresetUpdateButton").on('click', updateQuickReplyPreset);
$('#quickReplyContainer').sortable({
delay: getSortableDelay(),
stop: saveQROrder,
});
$("#quickReplyPresets").on('change', async function () {
const quickReplyPresetSelected = $(this).find(':selected').val();
@ -417,6 +883,11 @@ jQuery(async () => {
await loadSettings('init');
addQuickReplyBar();
eventSource.on(event_types.MESSAGE_RECEIVED, onMessageReceived);
eventSource.on(event_types.MESSAGE_SENT, onMessageSent);
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
eventSource.on(event_types.APP_READY, onAppReady);
});
jQuery(() => {

View File

@ -0,0 +1,65 @@
import { MenuItem } from "./MenuItem.js";
export class ContextMenu {
/**@type {MenuItem[]}*/ itemList = [];
/**@type {Boolean}*/ isActive = false;
/**@type {HTMLElement}*/ root;
/**@type {HTMLElement}*/ menu;
constructor(/**@type {MenuItem[]}*/items) {
this.itemList = items;
items.forEach(item => {
item.onExpand = () => {
items.filter(it => it != item)
.forEach(it => it.collapse());
};
});
}
render() {
if (!this.root) {
const blocker = document.createElement('div'); {
this.root = blocker;
blocker.classList.add('ctx-blocker');
blocker.addEventListener('click', () => this.hide());
const menu = document.createElement('ul'); {
this.menu = menu;
menu.classList.add('list-group');
menu.classList.add('ctx-menu');
this.itemList.forEach(it => menu.append(it.render()));
blocker.append(menu);
}
}
}
return this.root;
}
show({ clientX, clientY }) {
if (this.isActive) return;
this.isActive = true;
this.render();
this.menu.style.bottom = `${window.innerHeight - clientY}px`;
this.menu.style.left = `${clientX}px`;
document.body.append(this.root);
}
hide() {
if (this.root) {
this.root.remove();
}
this.isActive = false;
}
toggle(/**@type {PointerEvent}*/evt) {
if (this.isActive) {
this.hide();
} else {
this.show(evt);
}
}
}

View File

@ -0,0 +1,20 @@
import { MenuItem } from "./MenuItem.js";
export class MenuHeader extends MenuItem {
constructor(/**@type {String}*/label) {
super(label, null, null);
}
render() {
if (!this.root) {
const item = document.createElement('li'); {
this.root = item;
item.classList.add('list-group-item');
item.classList.add('ctx-header');
item.append(this.label);
}
}
return this.root;
}
}

View File

@ -0,0 +1,76 @@
import { SubMenu } from "./SubMenu.js";
export class MenuItem {
/**@type {String}*/ label;
/**@type {Object}*/ value;
/**@type {Function}*/ callback;
/**@type {MenuItem[]}*/ childList = [];
/**@type {SubMenu}*/ subMenu;
/**@type {Boolean}*/ isForceExpanded = false;
/**@type {HTMLElement}*/ root;
/**@type {Function}*/ onExpand;
constructor(/**@type {String}*/label, /**@type {Object}*/value, /**@type {function}*/callback, /**@type {MenuItem[]}*/children = []) {
this.label = label;
this.value = value;
this.callback = callback;
this.childList = children;
}
render() {
if (!this.root) {
const item = document.createElement('li'); {
this.root = item;
item.classList.add('list-group-item');
item.classList.add('ctx-item');
item.title = this.value;
if (this.callback) {
item.addEventListener('click', (evt) => this.callback(evt, this));
}
item.append(this.label);
if (this.childList.length > 0) {
item.classList.add('ctx-has-children');
const sub = new SubMenu(this.childList);
this.subMenu = sub;
const trigger = document.createElement('div'); {
trigger.classList.add('ctx-expander');
trigger.textContent = '⋮';
trigger.addEventListener('click', (evt) => {
evt.stopPropagation();
this.toggle();
});
item.append(trigger);
}
item.addEventListener('mouseover', () => sub.show(item));
item.addEventListener('mouseleave', () => sub.hide());
}
}
}
return this.root;
}
expand() {
this.subMenu?.show(this.root);
if (this.onExpand) {
this.onExpand();
}
}
collapse() {
this.subMenu?.hide();
}
toggle() {
if (this.subMenu.isActive) {
this.expand();
} else {
this.collapse();
}
}
}

View File

@ -0,0 +1,64 @@
import { MenuItem } from "./MenuItem.js";
export class SubMenu {
/**@type {MenuItem[]}*/ itemList = [];
/**@type {Boolean}*/ isActive = false;
/**@type {HTMLElement}*/ root;
constructor(/**@type {MenuItem[]}*/items) {
this.itemList = items;
}
render() {
if (!this.root) {
const menu = document.createElement('ul'); {
this.root = menu;
menu.classList.add('list-group');
menu.classList.add('ctx-menu');
menu.classList.add('ctx-sub-menu');
this.itemList.forEach(it => menu.append(it.render()));
}
}
return this.root;
}
show(/**@type {HTMLElement}*/parent) {
if (this.isActive) return;
this.isActive = true;
this.render();
parent.append(this.root);
requestAnimationFrame(() => {
const rect = this.root.getBoundingClientRect();
console.log(window.innerHeight, rect);
if (rect.bottom > window.innerHeight - 5) {
this.root.style.top = `${window.innerHeight - 5 - rect.bottom}px`;
}
if (rect.right > window.innerWidth - 5) {
this.root.style.left = 'unset';
this.root.style.right = '100%';
}
});
}
hide() {
if (this.root) {
this.root.remove();
this.root.style.top = '';
this.root.style.left = '';
}
this.isActive = false;
}
toggle(/**@type {HTMLElement}*/parent) {
if (this.isActive) {
this.hide();
} else {
this.show(parent);
}
}
}

View File

@ -1,7 +1,9 @@
#quickReplyBar {
outline: none;
/*
padding: 5px 0;
border-bottom: 1px solid var(--SmartThemeBorderColor);
*/
margin: 0;
transition: 0.3s;
opacity: 0.7;
@ -12,7 +14,7 @@
display: none;
max-width: 100%;
overflow-x: auto;
order: 10;
order: 1;
}
#quickReplies {
@ -25,13 +27,20 @@
width: 100%;
}
#quickReplyPopoutButton {
position: absolute;
right: 5px;
top: 0px;
}
#quickReplies div {
color: var(--SmartThemeBodyColor);
background-color: var(--black50a);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 10px;
padding: 3px 5px;
width: min-content;
margin: 3px 0;
/* width: min-content; */
cursor: pointer;
transition: 0.3s;
display: flex;
@ -44,4 +53,61 @@
opacity: 1;
filter: brightness(1.2);
cursor: pointer;
}
}
.ctx-blocker {
/* backdrop-filter: blur(1px); */
/* background-color: rgba(0 0 0 / 10%); */
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 0;
z-index: 999;
}
.ctx-menu {
position: absolute;
overflow: visible;
}
.list-group .list-group-item.ctx-header {
font-weight: bold;
cursor: default;
}
.ctx-item+.ctx-header {
border-top: 1px solid;
}
.ctx-item {
position: relative;
}
.ctx-expander {
border-left: 1px solid;
margin-left: 1em;
text-align: center;
width: 2em;
}
.ctx-expander:hover {
font-weight: bold;
}
.ctx-sub-menu {
position: absolute;
top: 0;
left: 100%;
}
@media screen and (max-width: 1000px) {
.ctx-blocker {
position: absolute;
}
.list-group .list-group-item.ctx-item {
padding: 1em;
}
}

View File

@ -33,7 +33,7 @@
<small data-i18n="Replace With">Replace With</small>
</label>
<div>
<textarea
<textarea
class="regex_replace_string text_pole wide100p textarea_compact"
placeholder="Use {{match}} to include the matched text from the Find Regex"
rows="2"
@ -45,7 +45,7 @@
<small data-i18n="Trim Out">Trim Out</small>
</label>
<div>
<textarea
<textarea
class="regex_trim_strings text_pole wide100p textarea_compact"
placeholder="Globally trims any unwanted parts from a regex match before replacement. Separate each element by an enter."
rows="3"
@ -86,6 +86,10 @@
<input type="checkbox" name="only_format_display" />
<span data-i18n="Only Format Display">Only Format Display</span>
</label>
<label class="checkbox flex-container" title="Chat history won't change, only the prompt as the request is sent (on generation)">
<input type="checkbox" name="only_format_prompt"/>
<span data-i18n="Only Format Prompt (?)">Only Format Prompt (?)</span>
</label>
<label class="checkbox flex-container">
<input type="checkbox" name="run_on_edit" />
<span data-i18n="Run On Edit">Run On Edit</span>

View File

@ -24,12 +24,12 @@ function regexFromString(input) {
try {
// Parse input
var m = input.match(/(\/?)(.+)\1([a-z]*)/i);
// Invalid flags
if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3])) {
return RegExp(input);
}
// Create the regular expression
return new RegExp(m[2], m[3]);
} catch {
@ -38,19 +38,24 @@ function regexFromString(input) {
}
// Parent function to fetch a regexed version of a raw string
function getRegexedString(rawString, placement, { characterOverride, isMarkdown } = {}) {
function getRegexedString(rawString, placement, { characterOverride, isMarkdown, isPrompt } = {}) {
let finalString = rawString;
if (extension_settings.disabledExtensions.includes("regex") || !rawString || placement === undefined) {
return finalString;
}
extension_settings.regex.forEach((script) => {
if ((script.markdownOnly && !isMarkdown) || (!script.markdownOnly && isMarkdown)) {
return;
}
if (script.placement.includes(placement)) {
finalString = runRegexScript(script, finalString, { characterOverride });
if (
// Script applies to Markdown and input is Markdown
(script.markdownOnly && isMarkdown) ||
// Script applies to Generate and input is Generate
(script.promptOnly && isPrompt) ||
// Script applies to all cases when neither "only"s are true, but there's no need to do it when `isMarkdown`, the as source (chat history) should already be changed beforehand
(!script.markdownOnly && !script.promptOnly && !isMarkdown)
) {
if (script.placement.includes(placement)) {
finalString = runRegexScript(script, finalString, { characterOverride });
}
}
});
@ -91,7 +96,7 @@ function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
const subReplaceString = substituteRegexParams(
regexScript.replaceString,
trimCapturedMatch ?? trimFencedMatch,
{
{
characterOverride,
replaceStrategy: regexScript.replaceStrategy ?? regex_replace_strategy.REPLACE
}

View File

@ -76,10 +76,27 @@ async function loadRegexScripts() {
const scriptHtml = scriptTemplate.clone();
scriptHtml.attr('id', uuidv4());
scriptHtml.find('.regex_script_name').text(script.scriptName);
scriptHtml.find('.edit_existing_regex').on('click', async function() {
scriptHtml.find('.disable_regex').prop("checked", script.disabled ?? false)
.on('input', function () {
script.disabled = !!$(this).prop("checked");
saveSettingsDebounced();
});
scriptHtml.find('.regex-toggle-on').on('click', function () {
scriptHtml.find('.disable_regex').prop("checked", true).trigger('input');
});
scriptHtml.find('.regex-toggle-off').on('click', function () {
scriptHtml.find('.disable_regex').prop("checked", false).trigger('input');
});
scriptHtml.find('.edit_existing_regex').on('click', async function () {
await onRegexEditorOpenClick(scriptHtml.attr("id"));
});
scriptHtml.find('.delete_regex').on('click', async function() {
scriptHtml.find('.delete_regex').on('click', async function () {
const confirm = await callPopup("Are you sure you want to delete this regex script?", "confirm");
if (!confirm) {
return;
}
await deleteRegexScript({ existingId: scriptHtml.attr("id") });
});
@ -113,6 +130,9 @@ async function onRegexEditorOpenClick(existingId) {
editorHtml
.find(`input[name="only_format_display"]`)
.prop("checked", existingScript.markdownOnly ?? false);
editorHtml
.find(`input[name="only_format_prompt"]`)
.prop("checked", existingScript.promptOnly ?? false);
editorHtml
.find(`input[name="run_on_edit"]`)
.prop("checked", existingScript.runOnEdit ?? false);
@ -154,7 +174,7 @@ async function onRegexEditorOpenClick(existingId) {
editorHtml
.find(`input[name="replace_position"]`)
.filter(":checked")
.map(function() { return parseInt($(this).val()) })
.map(function () { return parseInt($(this).val()) })
.get()
.filter((e) => e !== NaN) || [],
disabled:
@ -165,6 +185,10 @@ async function onRegexEditorOpenClick(existingId) {
editorHtml
.find(`input[name="only_format_display"]`)
.prop("checked"),
promptOnly:
editorHtml
.find(`input[name="only_format_prompt"]`)
.prop("checked"),
runOnEdit:
editorHtml
.find(`input[name="run_on_edit"]`)
@ -197,6 +221,7 @@ function migrateSettings() {
script.placement = script.placement.filter((e) => e !== regex_placement.MD_DISPLAY);
script.markdownOnly = true
script.promptOnly = true
performSave = true;
}
@ -231,7 +256,7 @@ jQuery(async () => {
const settingsHtml = await $.get("scripts/extensions/regex/dropdown.html");
$("#extensions_settings2").append(settingsHtml);
$("#open_regex_editor").on("click", function() {
$("#open_regex_editor").on("click", function () {
onRegexEditorOpenClick(false);
});

View File

@ -2,6 +2,11 @@
<span class="drag-handle menu-handle">&#9776;</span>
<div class="regex_script_name flexGrow overflow-hidden"></div>
<div class="flex-container flexnowrap">
<label class="checkbox flex-container" for="regex_disable">
<input type="checkbox" name="regex_disable" class="disable_regex" />
<span class="regex-toggle-on fa-solid fa-toggle-on" title="Disable script"></span>
<span class="regex-toggle-off fa-solid fa-toggle-off" title="Enable script"></span>
</label>
<div class="edit_existing_regex menu_button">
<i class="fa-solid fa-pencil"></i>
</div>

View File

@ -5,6 +5,10 @@
flex-direction: row;
}
.regex_settings .checkbox {
align-items: center;
}
.regex-script-container {
margin-top: 10px;
margin-bottom: 10px;
@ -17,4 +21,34 @@
padding: 0 5px;
margin-top: 1px;
margin-bottom: 1px;
}
}
input.disable_regex {
display: none !important;
}
.regex-toggle-off {
cursor: pointer;
opacity: 0.5;
filter: grayscale(0.5);
}
.regex-toggle-on {
cursor: pointer;
}
.disable_regex:checked ~ .regex-toggle-off {
display: block;
}
.disable_regex:checked ~ .regex-toggle-on {
display: none;
}
.disable_regex:not(:checked) ~ .regex-toggle-off {
display: none;
}
.disable_regex:not(:checked) ~ .regex-toggle-on {
display: block;
}

View File

@ -0,0 +1,46 @@
import { getRequestHeaders } from "../../script.js";
import { extension_settings } from "../extensions.js";
import { SECRET_KEYS, secret_state } from "../secrets.js";
import { createThumbnail } from "../utils.js";
/**
* Generates a caption for an image using a multimodal model.
* @param {string} base64Img Base64 encoded image
* @param {string} prompt Prompt to use for captioning
* @returns {Promise<string>} Generated caption
*/
export async function getMultimodalCaption(base64Img, prompt) {
if (extension_settings.caption.multimodal_api === 'openai' && !secret_state[SECRET_KEYS.OPENAI]) {
throw new Error('OpenAI API key is not set.');
}
if (extension_settings.caption.multimodal_api === 'openrouter' && !secret_state[SECRET_KEYS.OPENROUTER]) {
throw new Error('OpenRouter API key is not set.');
}
// OpenRouter has a payload limit of ~2MB
const base64Bytes = base64Img.length * 0.75;
const compressionLimit = 2 * 1024 * 1024;
if (extension_settings.caption.multimodal_api === 'openrouter' && base64Bytes > compressionLimit) {
const maxSide = 1024;
base64Img = await createThumbnail(base64Img, maxSide, maxSide, 'image/jpeg');
}
const apiResult = await fetch('/api/openai/caption-image', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
image: base64Img,
prompt: prompt,
api: extension_settings.caption.multimodal_api || 'openai',
model: extension_settings.caption.multimodal_model || 'gpt-4-vision-preview',
}),
});
if (!apiResult.ok) {
throw new Error('Failed to caption image via OpenAI.');
}
const { caption } = await apiResult.json();
return caption;
}

View File

@ -0,0 +1,31 @@
<div id="sd_comfy_workflow_editor_template">
<div class="sd_comfy_workflow_editor">
<h3><strong>ComfyUI Workflow Editor: <span id="sd_comfy_workflow_editor_name"></span></strong></h3>
<div class="sd_comfy_workflow_editor_content">
<div class="flex-container flexFlowColumn sd_comfy_workflow_editor_workflow_container">
<label for="sd_comfy_workflow_editor_workflow">Workflow (JSON)</label>
<textarea id="sd_comfy_workflow_editor_workflow" class="text_pole wide100p textarea_compact flex1" placeholder="Put the ComfyUI's workflow (JSON) here and replace the variable settings with placeholders."></textarea>
</div>
<div class="sd_comfy_workflow_editor_placeholder_container">
<div>Placeholders</div>
<ul class="sd_comfy_workflow_editor_placeholder_list">
<li data-placeholder="prompt" class="sd_comfy_workflow_editor_not_found">"%prompt%"</li>
<li data-placeholder="negative_prompt" class="sd_comfy_workflow_editor_not_found">"%negative_prompt%"</li>
<li data-placeholder="model" class="sd_comfy_workflow_editor_not_found">"%model%"</li>
<li data-placeholder="vae" class="sd_comfy_workflow_editor_not_found">"%vae%"</li>
<li data-placeholder="sampler" class="sd_comfy_workflow_editor_not_found">"%sampler%"</li>
<li data-placeholder="scheduler" class="sd_comfy_workflow_editor_not_found">"%scheduler%"</li>
<li data-placeholder="steps" class="sd_comfy_workflow_editor_not_found">"%steps%"</li>
<li data-placeholder="scale" class="sd_comfy_workflow_editor_not_found">"%scale%"</li>
<li data-placeholder="width" class="sd_comfy_workflow_editor_not_found">"%width%"</li>
<li data-placeholder="height" class="sd_comfy_workflow_editor_not_found">"%height%"</li>
<li><hr></li>
<li data-placeholder="seed" class="sd_comfy_workflow_editor_not_found">
"%seed%"
<a href="javascript:;" class="notes-link"><span class="note-link-span" title="Will generate a new random seed in SillyTavern that is then used in the ComfyUI workflow.">?</span></a>
</li>
</ul>
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
{
"display_name": "Stable Diffusion",
"display_name": "Image Generation",
"loading_order": 10,
"requires": [],
"optional": [
"sd"
],
"generate_interceptor": "SD_ProcessTriggers",
"js": "index.js",
"css": "style.css",
"author": "Cohee#1207",

View File

@ -1,25 +1,31 @@
<div class="sd_settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b>Stable Diffusion</b>
<b>
Image Generation
<a href="https://docs.sillytavern.app/extras/extensions/stable-diffusion/" class="notes-link" target="_blank">
<span class="note-link-span">?</span>
</a>
</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<small><i>Use slash commands or the bottom Paintbrush button to generate images. Type <span class="monospace">/help</span> in chat for more details</i></small>
<br>
<label for="sd_refine_mode" class="checkbox_label" title="Allow to edit prompts manually before sending them to generation API">
<input id="sd_refine_mode" type="checkbox" />
Edit prompts before generation
</label>
<label for="sd_interactive_mode" class="checkbox_label" title="Automatically generate images when sending messages like 'send me a picture of cat'.">
<input id="sd_interactive_mode" type="checkbox" />
Interactive mode
</label>
<label for="sd_multimodal_captioning" class="checkbox_label" title="Use multimodal captioning to generate prompts for user and character portraits based on their avatars.">
<input id="sd_multimodal_captioning" type="checkbox" />
Use multimodal captioning for portraits
</label>
<label for="sd_expand" class="checkbox_label" title="Automatically extend prompts using text generation model">
<input id="sd_expand" type="checkbox" />
Auto-enhance prompts
</label>
<small>
This option uses an additional GPT-2 text generation model to add more details to the prompt generated by the main API.
Works best for SDXL image models. May not work well with other models, it is recommended to manually edit prompts in this case.
</small>
<label for="sd_source">Source</label>
<select id="sd_source">
<option value="extras">Extras API (local / remote)</option>
@ -27,6 +33,8 @@
<option value="auto">Stable Diffusion Web UI (AUTOMATIC1111)</option>
<option value="vlad">SD.Next (vladmandic)</option>
<option value="novel">NovelAI Diffusion</option>
<option value="openai">OpenAI (DALL-E)</option>
<option value="comfy">ComfyUI</option>
</select>
<div data-sd-source="auto">
<label for="sd_auto_url">SD Web UI URL</label>
@ -94,6 +102,47 @@
</div>
<i>Hint: Save an API key in the NovelAI API settings to use it here.</i>
</div>
<div data-sd-source="openai">
<small>These settings only apply to DALL-E 3</small>
<div class="flex-container">
<label for="sd_openai_style">Image Style</label>
<select id="sd_openai_style">
<option value="vivid">Vivid</option>
<option value="natural">Natural</option>
</select>
<label for="sd_openai_quality">Image Quality</label>
<select id="sd_openai_quality">
<option value="standard">Standard</option>
<option value="hd">HD</option>
</select>
</div>
</div>
<div data-sd-source="comfy">
<label for="sd_comfy_url">ComfyUI URL</label>
<div class="flex-container flexnowrap">
<input id="sd_comfy_url" type="text" class="text_pole" placeholder="Example: {{comfy_url}}" value="{{comfy_url}}" />
<div id="sd_comfy_validate" class="menu_button menu_button_icon">
<i class="fa-solid fa-check"></i>
<span data-i18n="Connect">
Connect
</span>
</div>
</div>
<p><i><b>Important:</b> The server must be accessible from the SillyTavern host machine.</i></p>
<label for="sd_comfy_workflow">ComfyUI Workflow</label>
<div class="flex-container flexnowrap">
<select id="sd_comfy_workflow" class="flex1 text_pole"></select>
<div id="sd_comfy_open_workflow_editor" class="menu_button menu_button_icon" title="Open workflow editor">
<i class="fa-solid fa-pen-to-square"></i>
</div>
<div id="sd_comfy_new_workflow" class="menu_button menu_button_icon" title="Create new workflow">
<i class="fa-solid fa-plus"></i>
</div>
<div id="sd_comfy_delete_workflow" class="menu_button menu_button_icon" title="Delete workflow">
<i class="fa-solid fa-trash-can"></i>
</div>
</div>
</div>
<label for="sd_scale">CFG Scale (<span id="sd_scale_value"></span>)</label>
<input id="sd_scale" type="range" min="{{scale_min}}" max="{{scale_max}}" step="{{scale_step}}" value="{{scale}}" />
<label for="sd_steps">Sampling steps (<span id="sd_steps_value"></span>)</label>
@ -102,10 +151,20 @@
<input id="sd_width" type="range" max="{{dimension_max}}" min="{{dimension_min}}" step="{{dimension_step}}" value="{{width}}" />
<label for="sd_height">Height (<span id="sd_height_value"></span>)</label>
<input id="sd_height" type="range" max="{{dimension_max}}" min="{{dimension_min}}" step="{{dimension_step}}" value="{{height}}" />
<label for="sd_model">Stable Diffusion model</label>
<label for="sd_model">Model</label>
<select id="sd_model"></select>
<label for="sd_sampler">Sampling method</label>
<select id="sd_sampler"></select>
<label for="sd_resolution">Resolution</label>
<select id="sd_resolution"><!-- Populated in JS --></select>
<div data-sd-source="comfy">
<label for="sd_scheduler">Scheduler</label>
<select id="sd_scheduler"></select>
</div>
<div data-sd-source="comfy">
<label for="sd_vae">VAE</label>
<select id="sd_vae"></select>
</div>
<div class="flex-container marginTop10 margin-bot-10px">
<label class="flex1 checkbox_label">
<input id="sd_restore_faces" type="checkbox" />
@ -153,7 +212,7 @@
</div>
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b>SD Prompt Templates</b>
<b>Image Prompt Templates</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div id="sd_prompt_templates" class="inline-drawer-content">

View File

@ -27,3 +27,58 @@
z-index: 30000;
backdrop-filter: blur(--SmartThemeBlurStrength);
}
#sd_comfy_open_workflow_editor {
display: flex;
flex-direction: row;
gap: 10px;
width: fit-content;
}
#sd_comfy_workflow_editor_template {
height: 100%;
}
.sd_comfy_workflow_editor {
display: flex;
flex-direction: column;
height: 100%;
}
.sd_comfy_workflow_editor_content {
display: flex;
flex: 1 1 auto;
flex-direction: row;
}
.sd_comfy_workflow_editor_workflow_container {
flex: 1 1 auto;
}
#sd_comfy_workflow_editor_workflow {
font-family: monospace;
}
.sd_comfy_workflow_editor_placeholder_container {
flex: 0 0 auto;
}
.sd_comfy_workflow_editor_placeholder_list {
font-size: x-small;
list-style: none;
margin: 5px 0;
padding: 3px 5px;
text-align: left;
}
.sd_comfy_workflow_editor_placeholder_list>li[data-placeholder]:before {
content: "✅ ";
}
.sd_comfy_workflow_editor_placeholder_list>li.sd_comfy_workflow_editor_not_found:before {
content: "❌ ";
}
.sd_comfy_workflow_editor_placeholder_list>li>.notes-link {
cursor: help;
}

View File

@ -1,33 +1,119 @@
import { callPopup, main_api } from "../../../script.js";
import { getContext } from "../../extensions.js";
import { registerSlashCommand } from "../../slash-commands.js";
import { getTokenCount, getTokenizerModel } from "../../tokenizers.js";
import { getFriendlyTokenizerName, getTextTokens, getTokenCount, tokenizers } from "../../tokenizers.js";
import { resetScrollHeight } from "../../utils.js";
function rgb2hex(rgb) {
rgb = rgb.match(/^rgba?[\s+]?\([\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?,[\s+]?(\d+)[\s+]?/i);
return (rgb && rgb.length === 4) ? "#" +
("0" + parseInt(rgb[1], 10).toString(16)).slice(-2) +
("0" + parseInt(rgb[2], 10).toString(16)).slice(-2) +
("0" + parseInt(rgb[3], 10).toString(16)).slice(-2) : '';
}
$('button').click(function () {
var hex = rgb2hex($('input').val());
$('.result').html(hex);
});
async function doTokenCounter() {
const selectedTokenizer = main_api == 'openai'
? `tiktoken (${getTokenizerModel()})`
: $("#tokenizer").find(':selected').text();
const { tokenizerName, tokenizerId } = getFriendlyTokenizerName(main_api);
const html = `
<div class="wide100p">
<h3>Token Counter</h3>
<div class="justifyLeft">
<div class="justifyLeft flex-container flexFlowColumn">
<h4>Type / paste in the box below to see the number of tokens in the text.</h4>
<p>Selected tokenizer: ${selectedTokenizer}</p>
<textarea id="token_counter_textarea" class="wide100p textarea_compact margin-bot-10px" rows="20"></textarea>
<p>Selected tokenizer: ${tokenizerName}</p>
<div>Input:</div>
<textarea id="token_counter_textarea" class="wide100p textarea_compact" rows="1"></textarea>
<div>Tokens: <span id="token_counter_result">0</span></div>
<hr>
<div>Tokenized text:</div>
<div id="tokenized_chunks_display" class="wide100p">—</div>
<hr>
<div>Token IDs:</div>
<textarea id="token_counter_ids" class="wide100p textarea_compact" disabled rows="1">—</textarea>
</div>
</div>`;
const dialog = $(html);
dialog.find('#token_counter_textarea').on('input', () => {
const text = $('#token_counter_textarea').val();
const context = getContext();
const count = context.getTokenCount(text);
$('#token_counter_result').text(count);
const text = String($('#token_counter_textarea').val());
const ids = main_api == 'openai' ? getTextTokens(tokenizers.OPENAI, text) : getTextTokens(tokenizerId, text);
if (Array.isArray(ids) && ids.length > 0) {
$('#token_counter_ids').text(`[${ids.join(', ')}]`);
$('#token_counter_result').text(ids.length);
if (Object.hasOwnProperty.call(ids, 'chunks')) {
drawChunks(Object.getOwnPropertyDescriptor(ids, 'chunks').value, ids);
}
} else {
const context = getContext();
const count = context.getTokenCount(text);
$('#token_counter_ids').text('—');
$('#token_counter_result').text(count);
$('#tokenized_chunks_display').text('—');
}
resetScrollHeight($('#token_counter_textarea'));
resetScrollHeight($('#token_counter_ids'));
});
$('#dialogue_popup').addClass('wide_dialogue_popup');
callPopup(dialog, 'text');
callPopup(dialog, 'text', '', { wide: true, large: true });
}
/**
* Draws the tokenized chunks in the UI
* @param {string[]} chunks
* @param {number[]} ids
*/
function drawChunks(chunks, ids) {
const main_text_color = rgb2hex((getComputedStyle(document.documentElement).getPropertyValue('--SmartThemeBodyColor').trim()))
const italics_text_color = rgb2hex((getComputedStyle(document.documentElement).getPropertyValue('--SmartThemeEmColor').trim()))
const quote_text_color = rgb2hex((getComputedStyle(document.documentElement).getPropertyValue('--SmartThemeQuoteColor').trim()))
const blur_tint_color = rgb2hex((getComputedStyle(document.documentElement).getPropertyValue('--SmartThemeBlurTintColor').trim()))
const chat_tint_color = rgb2hex((getComputedStyle(document.documentElement).getPropertyValue('--SmartThemeChatTintColor').trim()))
const user_mes_blur_tint_color = rgb2hex((getComputedStyle(document.documentElement).getPropertyValue('--SmartThemeUserMesBlurTintColor').trim()))
const bot_mes_blur_tint_color = rgb2hex((getComputedStyle(document.documentElement).getPropertyValue('--SmartThemeBotMesBlurTintColor').trim()))
const shadow_color = rgb2hex((getComputedStyle(document.documentElement).getPropertyValue('--SmartThemeShadowColor').trim()))
const border_color = rgb2hex((getComputedStyle(document.documentElement).getPropertyValue('--SmartThemeBorderColor').trim()))
const pastelRainbow = [
//main_text_color,
//italics_text_color,
//quote_text_color,
'#FFB3BA',
'#FFDFBA',
'#FFFFBA',
'#BFFFBF',
'#BAE1FF',
'#FFBAF3',
];
$('#tokenized_chunks_display').empty();
for (let i = 0; i < chunks.length; i++) {
let chunk = chunks[i].replace(/▁/g, ' '); // This is a leading space in sentencepiece. More info: Lower one eighth block (U+2581)
// If <0xHEX>, decode it
if (/^<0x[0-9A-F]+>$/i.test(chunk)) {
const code = parseInt(chunk.substring(3, chunk.length - 1), 16);
chunk = String.fromCodePoint(code);
}
// If newline - insert a line break
if (chunk === '\n') {
$('#tokenized_chunks_display').append('<br>');
continue;
}
const color = pastelRainbow[i % pastelRainbow.length];
const chunkHtml = $(`<code style="background-color: ${color};">${chunk}</code>`);
chunkHtml.attr('title', ids[i]);
$('#tokenized_chunks_display').append(chunkHtml);
}
}
function doCount() {

View File

@ -0,0 +1,6 @@
#tokenized_chunks_display > code {
color: black;
text-shadow: none;
padding: 2px;
display: inline-block;
}

View File

@ -45,6 +45,8 @@ class ElevenLabsTtsProvider {
this.settings.stability = $('#elevenlabs_tts_stability').val()
this.settings.similarity_boost = $('#elevenlabs_tts_similarity_boost').val()
this.settings.model = $('#elevenlabs_tts_model').find(':selected').val()
$('#elevenlabs_tts_stability_output').text(this.settings.stability);
$('#elevenlabs_tts_similarity_boost_output').text(this.settings.similarity_boost);
saveTtsProviderSettings()
}
@ -79,6 +81,8 @@ class ElevenLabsTtsProvider {
$('#elevenlabs_tts_similarity_boost').on('input', this.onSettingsChange.bind(this))
$('#elevenlabs_tts_stability').on('input', this.onSettingsChange.bind(this))
$('#elevenlabs_tts_model').on('change', this.onSettingsChange.bind(this))
$('#elevenlabs_tts_stability_output').text(this.settings.stability);
$('#elevenlabs_tts_similarity_boost_output').text(this.settings.similarity_boost);
try {
await this.checkReady()

View File

@ -1,4 +1,4 @@
import { callPopup, cancelTtsPlay, eventSource, event_types, saveSettingsDebounced } from '../../../script.js'
import { callPopup, cancelTtsPlay, eventSource, event_types, name2, saveSettingsDebounced } from '../../../script.js'
import { ModuleWorkerWrapper, doExtrasFetch, extension_settings, getApiUrl, getContext, modules } from '../../extensions.js'
import { escapeRegex, getStringHash } from '../../utils.js'
import { EdgeTtsProvider } from './edge.js'
@ -8,6 +8,9 @@ import { CoquiTtsProvider } from './coqui.js'
import { SystemTtsProvider } from './system.js'
import { NovelTtsProvider } from './novel.js'
import { power_user } from '../../power-user.js'
import { registerSlashCommand } from '../../slash-commands.js'
import { OpenAITtsProvider } from './openai.js'
import {XTTSTtsProvider} from "./xtts.js"
export { talkingAnimation };
const UPDATE_INTERVAL = 1000
@ -68,14 +71,18 @@ export function getPreviewString(lang) {
let ttsProviders = {
ElevenLabs: ElevenLabsTtsProvider,
Silero: SileroTtsProvider,
XTTSv2: XTTSTtsProvider,
System: SystemTtsProvider,
Coqui: CoquiTtsProvider,
Edge: EdgeTtsProvider,
Novel: NovelTtsProvider,
OpenAI: OpenAITtsProvider,
}
let ttsProvider
let ttsProviderName
let ttsLastMessage = null;
async function onNarrateOneMessage() {
audioElement.src = '/sounds/silence.mp3';
const context = getContext();
@ -91,6 +98,36 @@ async function onNarrateOneMessage() {
moduleWorker();
}
async function onNarrateText(args, text) {
if (!text) {
return;
}
audioElement.src = '/sounds/silence.mp3';
// To load all characters in the voice map, set unrestricted to true
await initVoiceMap(true);
const baseName = args?.voice || name2;
const name = (baseName === 'SillyTavern System' ? DEFAULT_VOICE_MARKER : baseName) || DEFAULT_VOICE_MARKER;
const voiceMapEntry = voiceMap[name] === DEFAULT_VOICE_MARKER
? voiceMap[DEFAULT_VOICE_MARKER]
: voiceMap[name];
if (!voiceMapEntry || voiceMapEntry === DISABLED_VOICE_MARKER) {
toastr.info(`Specified voice for ${name} was not found. Check the TTS extension settings.`);
return;
}
resetTtsPlayback()
ttsJobQueue.push({ mes: text, name: name });
await moduleWorker();
// Return back to the chat voices
await initVoiceMap(false);
}
async function moduleWorker() {
// Primarily determining when to add new chat to the TTS queue
const enabled = $('#tts_enabled').is(':checked')
@ -122,30 +159,53 @@ async function moduleWorker() {
) {
currentMessageNumber = context.chat.length ? context.chat.length : 0
saveLastValues()
// Force to speak on the first message in the new chat
if (context.chat.length === 1) {
lastMessageHash = -1;
}
return
}
// take the count of messages
let lastMessageNumber = context.chat.length ? context.chat.length : 0
let lastMessageNumber = context.chat.length ? context.chat.length : 0;
// There's no new messages
let diff = lastMessageNumber - currentMessageNumber
let hashNew = getStringHash((chat.length && chat[chat.length - 1].mes) ?? '')
let diff = lastMessageNumber - currentMessageNumber;
let hashNew = getStringHash((chat.length && chat[chat.length - 1].mes) ?? '');
if (diff == 0 && hashNew === lastMessageHash) {
return
// if messages got deleted, diff will be < 0
if (diff < 0) {
// necessary actions will be taken by the onChatDeleted() handler
return;
}
const message = chat[chat.length - 1]
// if no new messages, or same message, or same message hash, do nothing
if (diff == 0 && hashNew === lastMessageHash) {
return;
}
// We're currently swiping or streaming. Don't generate voice
if (
!message ||
message.mes === '...' ||
message.mes === '' ||
(context.streamingProcessor && !context.streamingProcessor.isFinished)
) {
return
// If streaming, wait for streaming to finish before processing new messages
if (context.streamingProcessor && !context.streamingProcessor.isFinished) {
return;
}
// clone message object, as things go haywire if message object is altered below (it's passed by reference)
const message = structuredClone(chat[chat.length - 1]);
// if last message within current message, message got extended. only send diff to TTS.
if (ttsLastMessage !== null && message.mes.indexOf(ttsLastMessage) !== -1) {
let tmp = message.mes;
message.mes = message.mes.replace(ttsLastMessage, '');
ttsLastMessage = tmp;
} else {
ttsLastMessage = message.mes;
}
// We're currently swiping. Don't generate voice
if (!message || message.mes === '...' || message.mes === '') {
return;
}
// Don't generate if message doesn't have a display text
@ -246,6 +306,7 @@ window.debugTtsPlayback = debugTtsPlayback
//##################//
let audioElement = new Audio()
audioElement.id = 'tts_audio'
audioElement.autoplay = true
let audioJobQueue = []
@ -396,7 +457,7 @@ let currentTtsJob // Null if nothing is currently being processed
let currentMessageNumber = 0
function completeTtsJob() {
console.info(`Current TTS job for ${currentTtsJob.name} completed.`)
console.info(`Current TTS job for ${currentTtsJob?.name} completed.`)
currentTtsJob = null
}
@ -441,6 +502,14 @@ async function processTtsQueue() {
const partJoiner = (ttsProvider?.separator || ' ... ');
text = matches ? matches.join(partJoiner) : text;
}
if (typeof ttsProvider?.processText === 'function') {
text = await ttsProvider.processText(text);
}
// Collapse newlines and spaces into single space
text = text.replace(/\s+/g, ' ').trim();
console.log(`TTS: ${text}`)
const char = currentTtsJob.name
@ -628,12 +697,44 @@ export function saveTtsProviderSettings() {
async function onChatChanged() {
await resetTtsPlayback()
await initVoiceMap()
ttsLastMessage = null
}
function getCharacters(){
async function onChatDeleted() {
const context = getContext()
// update internal references to new last message
lastChatId = context.chatId
currentMessageNumber = context.chat.length ? context.chat.length : 0
// compare against lastMessageHash. If it's the same, we did not delete the last chat item, so no need to reset tts queue
let messageHash = getStringHash((context.chat.length && context.chat[context.chat.length - 1].mes) ?? '')
if (messageHash === lastMessageHash) {
return
}
lastMessageHash = messageHash
ttsLastMessage = (context.chat.length && context.chat[context.chat.length - 1].mes) ?? '';
// stop any tts playback since message might not exist anymore
await resetTtsPlayback()
}
/**
* Get characters in current chat
* @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
* @returns {string[]} - Array of character names
*/
function getCharacters(unrestricted) {
const context = getContext()
if (unrestricted) {
const names = context.characters.map(char => char.name);
names.unshift(DEFAULT_VOICE_MARKER);
return names;
}
let characters = []
if (context.groupId === null){
if (context.groupId === null) {
// Single char chat
characters.push(DEFAULT_VOICE_MARKER)
characters.push(context.name1)
@ -645,7 +746,7 @@ function getCharacters(){
const group = context.groups.find(group => context.groupId == group.id)
for (let member of group.members) {
// Remove suffix
if (member.endsWith('.png')){
if (member.endsWith('.png')) {
member = member.slice(0, -4)
}
characters.push(member)
@ -655,15 +756,15 @@ function getCharacters(){
}
function sanitizeId(input) {
// Remove any non-alphanumeric characters except underscore (_) and hyphen (-)
let sanitized = input.replace(/[^a-zA-Z0-9-_]/g, '');
// Remove any non-alphanumeric characters except underscore (_) and hyphen (-)
let sanitized = input.replace(/[^a-zA-Z0-9-_]/g, '');
// Ensure first character is always a letter
if (!/^[a-zA-Z]/.test(sanitized)) {
sanitized = 'element_' + sanitized;
}
// Ensure first character is always a letter
if (!/^[a-zA-Z]/.test(sanitized)) {
sanitized = 'element_' + sanitized;
}
return sanitized;
return sanitized;
}
function parseVoiceMap(voiceMapString) {
@ -685,13 +786,13 @@ function parseVoiceMap(voiceMapString) {
*/
function updateVoiceMap() {
const tempVoiceMap = {}
for (const voice of voiceMapEntries){
if (voice.voiceId === null){
for (const voice of voiceMapEntries) {
if (voice.voiceId === null) {
continue
}
tempVoiceMap[voice.name] = voice.voiceId
}
if (Object.keys(tempVoiceMap).length !== 0){
if (Object.keys(tempVoiceMap).length !== 0) {
voiceMap = tempVoiceMap
console.log(`Voicemap updated to ${JSON.stringify(voiceMap)}`)
}
@ -706,13 +807,13 @@ class VoiceMapEntry {
name
voiceId
selectElement
constructor (name, voiceId=DEFAULT_VOICE_MARKER) {
constructor(name, voiceId = DEFAULT_VOICE_MARKER) {
this.name = name
this.voiceId = voiceId
this.selectElement = null
}
addUI(voiceIds){
addUI(voiceIds) {
let sanitizedName = sanitizeId(this.name)
let defaultOption = this.name === DEFAULT_VOICE_MARKER ?
`<option>${DISABLED_VOICE_MARKER}</option>` :
@ -728,7 +829,7 @@ class VoiceMapEntry {
$('#tts_voicemap_block').append(template)
// Populate voice ID select list
for (const voiceId of voiceIds){
for (const voiceId of voiceIds) {
const option = document.createElement('option');
option.innerText = voiceId.name;
option.value = voiceId.name;
@ -748,12 +849,12 @@ class VoiceMapEntry {
/**
* 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.
*/
export async function initVoiceMap(){
export async function initVoiceMap(unrestricted = false) {
// Gate initialization if not enabled or TTS Provider not ready. Prevents error popups.
const enabled = $('#tts_enabled').is(':checked')
if (!enabled){
if (!enabled) {
return
}
@ -771,18 +872,18 @@ export async function initVoiceMap(){
// Clear existing voiceMap state
$('#tts_voicemap_block').empty()
voiceMapEntries = []
// Get characters in current chat
const characters = getCharacters()
const characters = getCharacters(unrestricted);
// Get saved voicemap from provider settings, handling new and old representations
let voiceMapFromSettings = {}
if ("voiceMap" in extension_settings.tts[ttsProviderName]) {
// Handle previous representation
if (typeof extension_settings.tts[ttsProviderName].voiceMap === "string"){
if (typeof extension_settings.tts[ttsProviderName].voiceMap === "string") {
voiceMapFromSettings = parseVoiceMap(extension_settings.tts[ttsProviderName].voiceMap)
// Handle new representation
} else if (typeof extension_settings.tts[ttsProviderName].voiceMap === "object"){
// Handle new representation
} else if (typeof extension_settings.tts[ttsProviderName].voiceMap === "object") {
voiceMapFromSettings = extension_settings.tts[ttsProviderName].voiceMap
}
}
@ -797,13 +898,13 @@ export async function initVoiceMap(){
}
// Build UI using VoiceMapEntry objects
for (const character of characters){
if (character === "SillyTavern System"){
for (const character of characters) {
if (character === "SillyTavern System") {
continue
}
// Check provider settings for voiceIds
let voiceId
if (character in voiceMapFromSettings){
if (character in voiceMapFromSettings) {
voiceId = voiceMapFromSettings[character]
} else if (character === DEFAULT_VOICE_MARKER) {
voiceId = DISABLED_VOICE_MARKER
@ -897,5 +998,8 @@ $(document).ready(function () {
setInterval(wrapper.update.bind(wrapper), UPDATE_INTERVAL) // Init depends on all the things
eventSource.on(event_types.MESSAGE_SWIPED, resetTtsPlayback);
eventSource.on(event_types.CHAT_CHANGED, onChatChanged)
eventSource.on(event_types.MESSAGE_DELETED, onChatDeleted);
eventSource.on(event_types.GROUP_UPDATED, onChatChanged)
registerSlashCommand('speak', onNarrateText, ['narrate', 'tts'], `<span class="monospace">(text)</span> narrate any text using currently selected character's voice. Use voice="Character Name" argument to set other voice from the voice map, example: <tt>/speak voice="Donald Duck" Quack!</tt>`, true, true);
document.body.appendChild(audioElement);
})

View File

@ -19,6 +19,17 @@ class NovelTtsProvider {
customVoices: []
}
/**
* Perform any text processing before passing to TTS engine.
* @param {string} text Input text
* @returns {string} Processed text
*/
processText(text) {
// Novel reads tilde as a word. Replace with full stop
text = text.replace(/~/g, '.');
return text;
}
get settingsHtml() {
let html = `
<div class="novel_tts_hints">

View File

@ -0,0 +1,148 @@
import { getRequestHeaders } from "../../../script.js"
import { saveTtsProviderSettings } from "./index.js";
export { OpenAITtsProvider }
class OpenAITtsProvider {
static voices = [
{ name: 'Alloy', voice_id: 'alloy', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/alloy.wav' },
{ name: 'Echo', voice_id: 'echo', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/echo.wav' },
{ name: 'Fable', voice_id: 'fable', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/fable.wav' },
{ name: 'Onyx', voice_id: 'onyx', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/onyx.wav' },
{ name: 'Nova', voice_id: 'nova', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/nova.wav' },
{ name: 'Shimmer', voice_id: 'shimmer', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/shimmer.wav' },
];
settings
voices = []
separator = ' . '
audioElement = document.createElement('audio')
defaultSettings = {
voiceMap: {},
customVoices: [],
model: 'tts-1',
speed: 1,
}
get settingsHtml() {
let html = `
<div>Use OpenAI's TTS engine.</div>
<small>Hint: Save an API key in the OpenAI API settings to use it here.</small>
<div>
<label for="openai-tts-model">Model:</label>
<select id="openai-tts-model">
<optgroup label="Latest">
<option value="tts-1">tts-1</option>
<option value="tts-1-hd">tts-1-hd</option>
</optgroup>
<optgroup label="Snapshots">
<option value="tts-1-1106">tts-1-1106</option>
<option value="tts-1-hd-1106">tts-1-hd-1106</option>
</optgroup>
<select>
</div>
<div>
<label for="openai-tts-speed">Speed: <span id="openai-tts-speed-output"></span></label>
<input type="range" id="openai-tts-speed" value="1" min="0.25" max="4" step="0.25">
</div>`;
return html;
}
async loadSettings(settings) {
// Populate 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 {
throw `Invalid setting passed to TTS Provider: ${key}`;
}
}
$('#openai-tts-model').val(this.settings.model);
$('#openai-tts-model').on('change', () => {
this.onSettingsChange();
});
$('#openai-tts-speed').val(this.settings.speed);
$('#openai-tts-speed').on('input', () => {
this.onSettingsChange();
});
$('#openai-tts-speed-output').text(this.settings.speed);
await this.checkReady();
console.debug("OpenAI TTS: Settings loaded");
}
onSettingsChange() {
// Update dynamically
this.settings.model = String($('#openai-tts-model').find(':selected').val());
this.settings.speed = Number($('#openai-tts-speed').val());
$('#openai-tts-speed-output').text(this.settings.speed);
saveTtsProviderSettings();
}
async checkReady() {
await this.fetchTtsVoiceObjects();
}
async onRefreshClick() {
return;
}
async getVoice(voiceName) {
if (!voiceName) {
throw `TTS Voice name not provided`
}
const voice = OpenAITtsProvider.voices.find(voice => voice.voice_id === voiceName || voice.name === voiceName);
if (!voice) {
throw `TTS Voice not found: ${voiceName}`
}
return voice;
}
async generateTts(text, voiceId) {
const response = await this.fetchTtsGeneration(text, voiceId)
return response
}
async fetchTtsVoiceObjects() {
return OpenAITtsProvider.voices;
}
async previewTtsVoice(_) {
return;
}
async fetchTtsGeneration(inputText, voiceId) {
console.info(`Generating new TTS for voice_id ${voiceId}`)
const response = await fetch(`/api/openai/generate-voice`, {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
"text": inputText,
"voice": voiceId,
"model": this.settings.model,
"speed": this.settings.speed,
}),
});
if (!response.ok) {
toastr.error(response.statusText, 'TTS Generation Failed');
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response;
}
}

View File

@ -1,8 +1,8 @@
# Provider Requirements.
# Provider Requirements.
Because I don't know how, or if you can, and/or maybe I am just too lazy to implement interfaces in JS, here's the requirements of a provider that the extension needs to operate.
### class YourTtsProvider
#### Required
#### Required
Exported for use in extension index.js, and added to providers list in index.js
1. generateTts(text, voiceId)
2. fetchTtsVoiceObjects()
@ -13,8 +13,9 @@ Exported for use in extension index.js, and added to providers list in index.js
7. settingsHtml field
#### Optional
1. previewTtsVoice()
1. previewTtsVoice()
2. separator field
3. processText(text)
# Requirement Descriptions
### generateTts(text, voiceId)
@ -49,14 +50,14 @@ Return without error to let TTS extension know that the provider is ready.
Return an error to block the main TTS extension for initializing the provider and UI. The error will be put in the TTS extension UI directly.
### loadSettings(settingsObject)
Required.
Required.
Handle the input settings from the TTS extension on provider load.
Put code in here to load your provider settings.
### settings field
Required, used for storing any provider state that needs to be saved.
Anything stored in this field is automatically persisted under extension_settings[providerName] by the main extension in `saveTtsProviderSettings()`, as well as loaded when the provider is selected in `loadTtsProvider(provider)`.
TTS extension doesn't expect any specific contents.
TTS extension doesn't expect any specific contents.
### settingsHtml field
Required, injected into the TTS extension UI. Besides adding it, not relied on by TTS extension directly.
@ -68,4 +69,8 @@ Function to handle playing previews of voice samples if no direct preview_url is
### separator field
Optional.
Used when narrate quoted text is enabled.
Defines the string of characters used to introduce separation between between the groups of extracted quoted text sent to the provider. The provider will use this to introduce pauses by default using `...`
Defines the string of characters used to introduce separation between between the groups of extracted quoted text sent to the provider. The provider will use this to introduce pauses by default using `...`
### processText(text)
Optional.
A function applied to the input text before passing it to the TTS generator. Can be async.

View File

@ -146,8 +146,8 @@ class SystemTtsProvider {
$('#system_tts_pitch').val(this.settings.pitch || this.defaultSettings.pitch);
// Trigger updates
$('#system_tts_rate').on("input", () =>{this.onSettingsChange()})
$('#system_tts_rate').on("input", () => {this.onSettingsChange()})
$('#system_tts_rate').on("input", () => { this.onSettingsChange() })
$('#system_tts_rate').on("input", () => { this.onSettingsChange() })
$('#system_tts_pitch_output').text(this.settings.pitch);
$('#system_tts_rate_output').text(this.settings.rate);
@ -155,7 +155,7 @@ class SystemTtsProvider {
}
// Perform a simple readiness check by trying to fetch voiceIds
async checkReady(){
async checkReady() {
await this.fetchTtsVoiceObjects()
}
@ -171,10 +171,16 @@ class SystemTtsProvider {
return [];
}
return speechSynthesis
.getVoices()
.sort((a, b) => a.lang.localeCompare(b.lang) || a.name.localeCompare(b.name))
.map(x => ({ name: x.name, voice_id: x.voiceURI, preview_url: false, lang: x.lang }));
return new Promise((resolve) => {
setTimeout(() => {
const voices = speechSynthesis
.getVoices()
.sort((a, b) => a.lang.localeCompare(b.lang) || a.name.localeCompare(b.name))
.map(x => ({ name: x.name, voice_id: x.voiceURI, preview_url: false, lang: x.lang }));
resolve(voices);
}, 1);
});
}
previewTtsVoice(voiceId) {

View File

@ -0,0 +1,207 @@
import { doExtrasFetch, getApiUrl, modules } from "../../extensions.js"
import { saveTtsProviderSettings } from "./index.js"
export { XTTSTtsProvider }
class XTTSTtsProvider {
//########//
// Config //
//########//
settings
ready = false
voices = []
separator = '. '
/**
* Perform any text processing before passing to TTS engine.
* @param {string} text Input text
* @returns {string} Processed text
*/
processText(text) {
// Replace fancy ellipsis with "..."
text = text.replace(/…/g, '...');
// Remove quotes
text = text.replace(/["“”‘’]/g, '');
// Replace multiple "." with single "."
text = text.replace(/\.+/g, '.');
return text;
}
languageLabels = {
"Arabic": "ar",
"Brazilian Portuguese": "pt",
"Chinese": "zh-cn",
"Czech": "cs",
"Dutch": "nl",
"English": "en",
"French": "fr",
"German": "de",
"Italian": "it",
"Polish": "pl",
"Russian": "ru",
"Spanish": "es",
"Turkish": "tr",
"Japanese": "ja",
"Korean": "ko",
"Hungarian": "hu",
"Hindi": "hi",
}
defaultSettings = {
provider_endpoint: "http://localhost:8020",
language: "en",
voiceMap: {}
}
get settingsHtml() {
let html = `
<label for="xtts_api_language">Language</label>
<select id="xtts_api_language">`;
for (let language in this.languageLabels) {
if (this.languageLabels[language] == this.settings?.language) {
html += `<option value="${this.languageLabels[language]}" selected="selected">${language}</option>`;
continue
}
html += `<option value="${this.languageLabels[language]}">${language}</option>`;
}
html += `
</select>
<label for="xtts_tts_endpoint">Provider Endpoint:</label>
<input id="xtts_tts_endpoint" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.provider_endpoint}"/>
`;
html += `
<span>
<span>Use <a target="_blank" href="https://github.com/daswer123/xtts-api-server">XTTSv2 TTS Server</a>.</span>
`;
return html;
}
onSettingsChange() {
// Used when provider settings are updated from UI
this.settings.provider_endpoint = $('#xtts_tts_endpoint').val()
this.settings.language = $('#xtts_api_language').val()
saveTtsProviderSettings()
}
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 {
throw `Invalid setting passed to TTS Provider: ${key}`
}
}
const apiCheckInterval = setInterval(() => {
// Use Extras API if TTS support is enabled
if (modules.includes('tts') || modules.includes('xtts-tts')) {
const baseUrl = new URL(getApiUrl());
baseUrl.pathname = '/api/tts';
this.settings.provider_endpoint = baseUrl.toString();
$('#xtts_tts_endpoint').val(this.settings.provider_endpoint);
clearInterval(apiCheckInterval);
}
}, 2000);
$('#xtts_tts_endpoint').val(this.settings.provider_endpoint)
$('#xtts_tts_endpoint').on("input", () => { this.onSettingsChange() })
$('#xtts_api_language').val(this.settings.language)
$('#xtts_api_language').on("change", () => { this.onSettingsChange() })
await this.checkReady()
console.debug("XTTS: Settings loaded")
}
// Perform a simple readiness check by trying to fetch voiceIds
async checkReady() {
const response = await this.fetchTtsVoiceObjects()
}
async onRefreshClick() {
return
}
//#################//
// TTS Interfaces //
//#################//
async getVoice(voiceName) {
if (this.voices.length == 0) {
this.voices = await this.fetchTtsVoiceObjects()
}
const match = this.voices.filter(
XTTSVoice => XTTSVoice.name == voiceName
)[0]
if (!match) {
throw `TTS Voice name ${voiceName} not found`
}
return match
}
async generateTts(text, voiceId) {
const response = await this.fetchTtsGeneration(text, voiceId)
return response
}
//###########//
// API CALLS //
//###########//
async fetchTtsVoiceObjects() {
const response = await doExtrasFetch(`${this.settings.provider_endpoint}/speakers`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
}
const responseJson = await response.json()
return responseJson
}
async fetchTtsGeneration(inputText, voiceId) {
console.info(`Generating new TTS for voice_id ${voiceId}`)
const response = await doExtrasFetch(
`${this.settings.provider_endpoint}/tts_to_audio/`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache' // Added this line to disable caching of file so new files are always played - Rolyat 7/7/23
},
body: JSON.stringify({
"text": inputText,
"speaker_wav": voiceId,
"language": this.settings.language
})
}
)
if (!response.ok) {
toastr.error(response.statusText, 'TTS Generation Failed');
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response
}
// Interface not used by XTTS TTS
async fetchTtsFromHistory(history_item_id) {
return Promise.resolve(history_item_id);
}
}

View File

@ -19,10 +19,10 @@
</select>
<div id="vectors_advanced_settings" data-newbie-hidden>
<label for="vectors_template">
Insertion template:
Insertion Template
</label>
<textarea id="vectors_template" class="text_pole textarea_compact autoSetHeight" rows="2" placeholder="Use {{text}} macro to specify the position of retrieved text."></textarea>
<label for="vectors_position">Injection position:</label>
<label for="vectors_position">Injection Position</label>
<div class="radio_group">
<label>
<input type="radio" name="vectors_position" value="2" />