Merge branch 'dev' into ouoertheo/objective3

This commit is contained in:
ouoertheo
2023-06-06 00:07:11 -05:00
committed by GitHub
71 changed files with 3502 additions and 1053 deletions

View File

@ -1,5 +1,5 @@
import { getBase64Async } from "../../utils.js";
import { getContext, getApiUrl } from "../../extensions.js";
import { getContext, getApiUrl, doExtrasFetch } from "../../extensions.js";
export { MODULE_NAME };
const MODULE_NAME = 'caption';
@ -63,7 +63,7 @@ async function onSelectImage(e) {
const url = new URL(getApiUrl());
url.pathname = '/api/caption';
const apiResult = await fetch(url, {
const apiResult = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@ -93,5 +93,5 @@ jQuery(function () {
addDiceRollButton();
moduleWorker();
setInterval(moduleWorker, UPDATE_INTERVAL);
registerSlashCommand('roll', (_, value) => doDiceRoll(value), [], "<span class='monospace'>(dice formula)</span> roll the dice. For example, /roll 2d6", false, true);
registerSlashCommand('roll', (_, value) => doDiceRoll(value), ['r'], "<span class='monospace'>(dice formula)</span> roll the dice. For example, /roll 2d6", false, true);
});

View File

@ -1,9 +1,13 @@
import { callPopup, getRequestHeaders, saveSettingsDebounced } from "../../../script.js";
import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper } from "../../extensions.js";
import { callPopup, eventSource, event_types, getRequestHeaders, saveSettingsDebounced } from "../../../script.js";
import { deviceInfo, dragElement } from "../../RossAscends-mods.js";
import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch } from "../../extensions.js";
import { power_user } from "../../power-user.js";
import { onlyUnique, debounce } from "../../utils.js";
export { MODULE_NAME };
const MODULE_NAME = 'expressions';
const UPDATE_INTERVAL = 2000;
const FALLBACK_EXPRESSION = 'joy';
const DEFAULT_EXPRESSIONS = [
"admiration",
"amusement",
@ -41,6 +45,241 @@ let lastMessage = null;
let spriteCache = {};
let inApiCall = false;
function isVisualNovelMode() {
return Boolean(!deviceInfo.isMobile && power_user.waifuMode && getContext().groupId);
}
async function forceUpdateVisualNovelMode() {
if (isVisualNovelMode()) {
await updateVisualNovelMode();
}
}
const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, 100);
async function updateVisualNovelMode(name, expression) {
const container = $('#visual-novel-wrapper');
await visualNovelRemoveInactive(container);
const setSpritePromises = await visualNovelSetCharacterSprites(container, name, expression);
// calculate layer indices based on recent messages
await visualNovelUpdateLayers(container);
await Promise.allSettled(setSpritePromises);
// update again based on new sprites
if (setSpritePromises.length > 0) {
await visualNovelUpdateLayers(container);
}
}
async function visualNovelRemoveInactive(container) {
const context = getContext();
const group = context.groups.find(x => x.id == context.groupId);
const removeInactiveCharactersPromises = [];
// remove inactive characters after 1 second
container.find('.expression-holder').each((_, current) => {
const promise = new Promise(resolve => {
const element = $(current);
const avatar = element.data('avatar');
if (!group.members.includes(avatar) || group.disabled_members.includes(avatar)) {
element.fadeOut(250, () => {
element.remove();
resolve();
});
} else {
resolve();
}
});
removeInactiveCharactersPromises.push(promise);
});
await Promise.allSettled(removeInactiveCharactersPromises);
}
async function visualNovelSetCharacterSprites(container, name, expression) {
const context = getContext();
const group = context.groups.find(x => x.id == context.groupId);
const labels = await getExpressionsList();
const createCharacterPromises = [];
const setSpritePromises = [];
for (const avatar of group.members) {
const isDisabled = group.disabled_members.includes(avatar);
// skip disabled characters
if (isDisabled) {
continue;
}
const character = context.characters.find(x => x.avatar == avatar);
if (!character) {
continue;
}
let spriteFolderName = character.name;
const avatarFileName = getSpriteFolderName({ original_avatar: character.avatar });
const expressionOverride = extension_settings.expressionOverrides.find((e) =>
e.name == avatarFileName
);
if (expressionOverride && expressionOverride.path) {
spriteFolderName = expressionOverride.path;
}
// download images if not downloaded yet
if (spriteCache[spriteFolderName] === undefined) {
spriteCache[spriteFolderName] = await getSpritesList(spriteFolderName);
}
const sprites = spriteCache[spriteFolderName];
const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);
const defaultSpritePath = sprites.find(x => x.label === FALLBACK_EXPRESSION)?.path;
const noSprites = sprites.length === 0;
if (expressionImage.length > 0) {
if (name == spriteFolderName) {
await validateImages(spriteFolderName, true);
setExpressionOverrideHtml(true); // <= force clear expression override input
const currentSpritePath = labels.includes(expression) ? sprites.find(x => x.label === expression)?.path : '';
const path = currentSpritePath || defaultSpritePath || '';
const img = expressionImage.find('img');
setImage(img, path);
}
expressionImage.toggleClass('hidden', noSprites);
} else {
const template = $('#expression-holder').clone();
template.attr('id', `expression-${avatar}`);
template.attr('data-avatar', avatar);
template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
$('#visual-novel-wrapper').append(template);
dragElement(template[0]);
template.toggleClass('hidden', noSprites);
setImage(template.find('img'), defaultSpritePath || '');
const fadeInPromise = new Promise(resolve => {
template.fadeIn(250, () => resolve());
});
createCharacterPromises.push(fadeInPromise);
const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels);
setSpritePromises.push(setSpritePromise);
}
}
await Promise.allSettled(createCharacterPromises);
return setSpritePromises;
}
async function visualNovelUpdateLayers(container) {
const context = getContext();
const group = context.groups.find(x => x.id == context.groupId);
const recentMessages = context.chat.map(x => x.original_avatar).filter(x => x).reverse().filter(onlyUnique);
const filteredMembers = group.members.filter(x => !group.disabled_members.includes(x));
const layerIndices = filteredMembers.slice().sort((a, b) => recentMessages.indexOf(b) - recentMessages.indexOf(a));
const setLayerIndicesPromises = [];
const sortFunction = (a, b) => {
const avatarA = $(a).data('avatar');
const avatarB = $(b).data('avatar');
const indexA = filteredMembers.indexOf(avatarA);
const indexB = filteredMembers.indexOf(avatarB);
return indexA - indexB;
};
const containerWidth = container.width();
const pivotalPoint = containerWidth * 0.5;
let images = $('.expression-holder');
let imagesWidth = [];
images.sort(sortFunction).each(function () {
imagesWidth.push($(this).width());
});
let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
let currentPosition = pivotalPoint - (totalWidth / 2);
if (totalWidth > containerWidth) {
let totalOverlap = totalWidth - containerWidth;
let totalWidthWithoutWidest = imagesWidth.reduce((a, b) => a + b, 0) - Math.max(...imagesWidth);
let overlaps = imagesWidth.map(width => (width / totalWidthWithoutWidest) * totalOverlap);
imagesWidth = imagesWidth.map((width, index) => width - overlaps[index]);
currentPosition = 0; // Reset the initial position to 0
}
images.sort(sortFunction).each((index, current) => {
const element = $(current);
// skip repositioning of dragged elements
if (element.data('dragged')) {
currentPosition += imagesWidth[index];
return;
}
const avatar = element.data('avatar');
const layerIndex = layerIndices.indexOf(avatar);
element.css('z-index', layerIndex);
element.show();
const promise = new Promise(resolve => {
element.animate({ left: currentPosition + 'px' }, 500, () => {
resolve();
});
});
currentPosition += imagesWidth[index];
setLayerIndicesPromises.push(promise);
});
await Promise.allSettled(setLayerIndicesPromises);
}
async function setLastMessageSprite(img, avatar, labels) {
const context = getContext();
const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
if (lastMessage) {
const text = lastMessage.mes || '';
let spriteFolderName = lastMessage.name;
const avatarFileName = getSpriteFolderName(lastMessage);
const expressionOverride = extension_settings.expressionOverrides.find((e) =>
e.name == avatarFileName
);
if (expressionOverride && expressionOverride.path) {
spriteFolderName = expressionOverride.path;
}
const sprites = spriteCache[spriteFolderName] || [];
const label = await getExpressionLabel(text);
const path = labels.includes(label) ? sprites.find(x => x.label === label)?.path : '';
if (path) {
setImage(img, path);
}
}
}
function setImage(img, path) {
img.attr('src', path);
img.removeClass('default');
img.off('error');
img.on('error', function () {
console.debug('Error loading image', path);
$(this).off('error');
$(this).attr('src', '');
});
}
function onExpressionsShowDefaultInput() {
const value = $(this).prop('checked');
extension_settings.expressions.showDefault = value;
@ -73,11 +312,39 @@ async function moduleWorker() {
spriteCache = {};
}
const vnMode = isVisualNovelMode();
const vnWrapperVisible = $('#visual-novel-wrapper').is(':visible');
if (vnMode) {
$('#expression-wrapper').hide();
$('#visual-novel-wrapper').show();
} else {
$('#expression-wrapper').show();
$('#visual-novel-wrapper').hide();
}
const vnStateChanged = vnMode !== vnWrapperVisible;
if (vnStateChanged) {
lastMessage = null;
$('#visual-novel-wrapper').empty();
$("#expression-holder").css({ top: '', left: '', right: '', bottom: '', height: '', width: '', margin: '' });
}
const currentLastMessage = getLastCharacterMessage();
let spriteFolderName = currentLastMessage.name;
const avatarFileName = getSpriteFolderName(currentLastMessage);
const expressionOverride = extension_settings.expressionOverrides.find((e) =>
e.name == avatarFileName
);
if (expressionOverride && expressionOverride.path) {
spriteFolderName = expressionOverride.path;
}
// character has no expressions or it is not loaded
if (Object.keys(spriteCache).length === 0) {
await validateImages(currentLastMessage.name);
await validateImages(spriteFolderName);
lastCharacter = context.groupId || context.characterId;
}
@ -88,7 +355,8 @@ async function moduleWorker() {
lastCharacter = context.groupId || context.characterId;
if (context.groupId) {
await validateImages(currentLastMessage.name, true);
await validateImages(spriteFolderName, true);
await forceUpdateVisualNovelMode();
}
return;
@ -99,13 +367,13 @@ async function moduleWorker() {
expressionsList = null;
spriteCache = {};
expressionsList = await getExpressionsList();
await validateImages(currentLastMessage.name, true);
await validateImages(spriteFolderName, true);
await forceUpdateVisualNovelMode();
}
offlineMode.css('display', 'none');
}
// check if last message changed
if ((lastCharacter === context.characterId || lastCharacter === context.groupId)
&& lastMessage === currentLastMessage.mes) {
@ -119,32 +387,22 @@ async function moduleWorker() {
try {
inApiCall = true;
const url = new URL(getApiUrl());
url.pathname = '/api/classify';
let expression = await getExpressionLabel(currentLastMessage.mes);
const apiResult = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass',
},
body: JSON.stringify({ text: currentLastMessage.mes })
});
if (apiResult.ok) {
const name = context.groupId ? currentLastMessage.name : context.name2;
const force = !!context.groupId;
const data = await apiResult.json();
let expression = data.classification[0].label;
// Character won't be angry on you for swiping
if (currentLastMessage.mes == '...' && expressionsList.includes('joy')) {
expression = 'joy';
}
setExpression(name, expression, force);
// If we're not already overriding the folder name, account for group chats.
if (spriteFolderName === currentLastMessage.name && !context.groupId) {
spriteFolderName = context.name2;
}
const force = !!context.groupId;
// Character won't be angry on you for swiping
if (currentLastMessage.mes == '...' && expressionsList.includes(FALLBACK_EXPRESSION)) {
expression = FALLBACK_EXPRESSION;
}
await sendExpressionCall(spriteFolderName, expression, force, vnMode);
}
catch (error) {
console.log(error);
@ -156,6 +414,61 @@ async function moduleWorker() {
}
}
function getSpriteFolderName(message) {
const context = getContext();
let avatarPath = '';
if (context.groupId) {
avatarPath = message.original_avatar || context.characters.find(x => message.force_avatar && message.force_avatar.includes(encodeURIComponent(x.avatar)))?.avatar;
}
else if (context.characterId) {
avatarPath = context.characters[context.characterId].avatar;
}
if (!avatarPath) {
return '';
}
const folderName = avatarPath.replace(/\.[^/.]+$/, "");
return folderName;
}
async function sendExpressionCall(name, expression, force, vnMode) {
if (!vnMode) {
vnMode = isVisualNovelMode();
}
if (vnMode) {
await updateVisualNovelMode(name, expression);
} else {
setExpression(name, expression, force);
}
}
async function getExpressionLabel(text) {
// Return if text is undefined, saving a costly fetch request
if (!modules.includes('classify') || !text) {
return FALLBACK_EXPRESSION;
}
const url = new URL(getApiUrl());
url.pathname = '/api/classify';
const apiResult = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass',
},
body: JSON.stringify({ text: text }),
});
if (apiResult.ok) {
const data = await apiResult.json();
return data.classification[0].label;
}
}
function getLastCharacterMessage() {
const context = getContext();
const reversedChat = context.chat.slice().reverse();
@ -165,10 +478,10 @@ function getLastCharacterMessage() {
continue;
}
return { mes: mes.mes, name: mes.name };
return { mes: mes.mes, name: mes.name, original_avatar: mes.original_avatar, force_avatar: mes.force_avatar };
}
return { mes: '', name: null };
return { mes: '', name: null, original_avatar: null, force_avatar: null };
}
function removeExpression() {
@ -265,7 +578,7 @@ async function getExpressionsList() {
url.pathname = '/api/classify/labels';
try {
const apiResult = await fetch(url, {
const apiResult = await doExtrasFetch(url, {
method: 'GET',
headers: { 'Bypass-Tunnel-Reminder': 'bypass' },
});
@ -292,11 +605,32 @@ async function setExpression(character, expression, force) {
console.debug('checking for expression images to show..');
if (sprite) {
console.debug('setting expression from character images folder');
if (force && isVisualNovelMode()) {
const context = getContext();
const group = context.groups.find(x => x.id === context.groupId);
for (const member of group.members) {
const groupMember = context.characters.find(x => x.avatar === member);
if (!groupMember) {
continue;
}
if (groupMember.name == character) {
setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path);
return;
}
}
}
img.attr('src', sprite.path);
img.removeClass('default');
img.off('error');
img.on('error', function () {
console.debug('Expression image error', sprite.path);
$(this).attr('src', '');
$(this).off('error');
if (force && extension_settings.expressions.showDefault) {
setDefault();
}
@ -384,6 +718,86 @@ async function onClickExpressionUpload(event) {
.trigger('click');
}
async function onClickExpressionOverrideButton() {
const context = getContext();
const currentLastMessage = getLastCharacterMessage();
const avatarFileName = getSpriteFolderName(currentLastMessage);
// If the avatar name couldn't be found, abort.
if (!avatarFileName) {
console.debug(`Could not find filename for character with name ${currentLastMessage.name} and ID ${context.characterId}`);
return;
}
const overridePath = $("#expression_override").val();
const existingOverrideIndex = extension_settings.expressionOverrides.findIndex((e) =>
e.name == avatarFileName
);
// If the path is empty, delete the entry from overrides
if (overridePath === undefined || overridePath.length === 0) {
if (existingOverrideIndex === -1) {
return;
}
extension_settings.expressionOverrides.splice(existingOverrideIndex, 1);
console.debug(`Removed existing override for ${avatarFileName}`);
} else {
// Properly override objects and clear the sprite cache of the previously set names
const existingOverride = extension_settings.expressionOverrides[existingOverrideIndex];
if (existingOverride) {
Object.assign(existingOverride, { path: overridePath });
delete spriteCache[existingOverride.name];
} else {
const characterOverride = { name: avatarFileName, path: overridePath };
extension_settings.expressionOverrides.push(characterOverride);
delete spriteCache[currentLastMessage.name];
}
console.debug(`Added/edited expression override for character with filename ${avatarFileName} to folder ${overridePath}`);
}
saveSettingsDebounced();
// Refresh sprites list. Assume the override path has been properly handled.
try {
$('#visual-novel-wrapper').empty();
await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
const expression = await getExpressionLabel(currentLastMessage.mes);
await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, true);
forceUpdateVisualNovelMode();
} catch (error) {
console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
}
}
async function onClickExpressionOverrideRemoveAllButton() {
// Remove all the overrided entries from sprite cache
for (const element of extension_settings.expressionOverrides) {
delete spriteCache[element.name];
}
extension_settings.expressionOverrides = [];
saveSettingsDebounced();
console.debug("All expression image overrides have been cleared.");
// Refresh sprites list to use the default name if applicable
try {
$('#visual-novel-wrapper').empty();
const currentLastMessage = getLastCharacterMessage();
await validateImages(currentLastMessage.name, true);
const expression = await getExpressionLabel(currentLastMessage.mes);
await sendExpressionCall(currentLastMessage.name, expression, true);
forceUpdateVisualNovelMode();
console.debug(extension_settings.expressionOverrides);
} catch (error) {
console.debug(`The current expression could not be set because of error: ${error}`);
}
}
async function onClickExpressionUploadPackButton() {
const name = $('#image_list').data('name');
@ -439,6 +853,28 @@ async function onClickExpressionDelete(event) {
await validateImages(name);
}
function setExpressionOverrideHtml(forceClear = false) {
const currentLastMessage = getLastCharacterMessage();
const avatarFileName = getSpriteFolderName(currentLastMessage);
if (!avatarFileName) {
return;
}
const expressionOverride = extension_settings.expressionOverrides.find((e) =>
e.name == avatarFileName
);
if (expressionOverride && expressionOverride.path) {
$("#expression_override").val(expressionOverride.path);
} else if (expressionOverride) {
delete extension_settings.expressionOverrides[expressionOverride.name];
}
if (forceClear && !expressionOverride) {
$("#expression_override").val("");
}
}
(function () {
function addExpressionImage() {
const html = `
@ -450,6 +886,14 @@ async function onClickExpressionDelete(event) {
</div>`;
$('body').append(html);
}
function addVisualNovelMode() {
const html = `
<div id="visual-novel-wrapper">
</div>`
const element = $(html);
element.hide();
$('body').append(element);
}
function addSettings() {
const html = `
@ -461,12 +905,20 @@ async function onClickExpressionDelete(event) {
</div>
<div class="inline-drawer-content">
<p class="offline_mode">You are in offline mode. Click on the image below to set the expression.</p>
<div class="flex-container flexnowrap">
<input id="expression_override" type="text" class="text_pole" placeholder="Override folder name" />
<input id="expression_override_button" class="menu_button" type="submit" value="Submit" />
</div>
<div id="image_list"></div>
<div class="expression_buttons">
<div class="expression_buttons flex-container spaceEvenly">
<div id="expression_upload_pack_button" class="menu_button">
<i class="fa-solid fa-file-zipper"></i>
<span>Upload sprite pack (ZIP)</span>
</div>
<div id="expression_override_cleanup_button" class="menu_button">
<i class="fa-solid fa-trash-can"></i>
<span>Remove all image overrides</span>
</div>
</div>
<p class="hint"><b>Hint:</b> <i>Create new folder in the <b>public/characters/</b> folder and name it as the name of the character.
Put images with expressions there. File names should follow the pattern: <tt>[expression_label].[image_format]</tt></i></p>
@ -480,18 +932,32 @@ async function onClickExpressionDelete(event) {
</div>
`;
$('#extensions_settings').append(html);
$('#expression_override_button').on('click', onClickExpressionOverrideButton);
$('#expressions_show_default').on('input', onExpressionsShowDefaultInput);
$('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
$('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input');
$('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
$(document).on('click', '.expression_list_item', onClickExpressionImage);
$(document).on('click', '.expression_list_upload', onClickExpressionUpload);
$(document).on('click', '.expression_list_delete', onClickExpressionDelete);
$(window).on("resize", updateVisualNovelModeDebounced);
$('.expression_settings').hide();
}
addExpressionImage();
addVisualNovelMode();
addSettings();
const wrapper = new ModuleWorkerWrapper(moduleWorker);
setInterval(wrapper.update.bind(wrapper), UPDATE_INTERVAL);
const updateFunction = wrapper.update.bind(wrapper);
setInterval(updateFunction, UPDATE_INTERVAL);
moduleWorker();
eventSource.on(event_types.CHAT_CHANGED, () => {
setExpressionOverrideHtml();
if (isVisualNovelMode()) {
$('#visual-novel-wrapper').empty();
}
});
eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
})();

View File

@ -10,6 +10,27 @@
width: 100vw;
}
#visual-novel-wrapper {
display: flex;
height: calc(100vh - 40px);
width: 100vw;
position: relative;
overflow: hidden;
}
#visual-novel-wrapper .expression-holder {
width: max-content;
}
#visual-novel-wrapper .hidden {
display: none !important;
visibility: hidden !important;
}
/*#visual-novel-wrapper img.expression {
object-fit: cover;
}*/
.expression-holder {
min-width: 100px;
min-height: 100px;
@ -54,6 +75,7 @@ img.expression.default {
.expression_list_item {
position: relative;
max-width: 20%;
min-width: 100px;
max-height: 200px;
background-color: #515151b0;
border-radius: 10px;
@ -87,10 +109,16 @@ img.expression.default {
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 20%;
/* height: 20%; */
padding: 0.25rem;
}
.menu_button.expression_list_delete,
.menu_button.expression_list_upload {
margin-top: 0;
margin-bottom: 0;
}
.expression_list_image {
max-width: 100%;
height: 100%;
@ -143,4 +171,4 @@ img.expression.default {
div.expression {
display: none;
}
}
}

View File

@ -1,6 +1,6 @@
import { saveSettingsDebounced, getCurrentChatId, system_message_types, eventSource, event_types } from "../../../script.js";
import { humanizedDateTime } from "../../RossAscends-mods.js";
import { getApiUrl, extension_settings, getContext } from "../../extensions.js";
import { getApiUrl, extension_settings, getContext, doExtrasFetch } from "../../extensions.js";
import { getFileText, onlyUnique, splitRecursive, IndexedDBStore } from "../../utils.js";
export { MODULE_NAME };
@ -174,7 +174,7 @@ async function addMessages(chat_id, messages) {
meta: JSON.stringify(m),
}));
const addMessagesResult = await fetch(url, {
const addMessagesResult = await doExtrasFetch(url, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify({ chat_id, messages: transformedMessages }),
@ -222,7 +222,7 @@ async function onPurgeClick() {
const url = new URL(getApiUrl());
url.pathname = '/api/chromadb/purge';
const purgeResult = await fetch(url, {
const purgeResult = await doExtrasFetch(url, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify({ chat_id }),
@ -242,7 +242,7 @@ async function onExportClick() {
const url = new URL(getApiUrl());
url.pathname = '/api/chromadb/export';
const exportResult = await fetch(url, {
const exportResult = await doExtrasFetch(url, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify({ chat_id: currentChatId }),
@ -285,7 +285,7 @@ async function onSelectImportFile(e) {
const url = new URL(getApiUrl());
url.pathname = '/api/chromadb/import';
const importResult = await fetch(url, {
const importResult = await doExtrasFetch(url, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify(imported),
@ -313,7 +313,7 @@ async function queryMessages(chat_id, query) {
const url = new URL(getApiUrl());
url.pathname = '/api/chromadb/query';
const queryMessagesResult = await fetch(url, {
const queryMessagesResult = await doExtrasFetch(url, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify({ chat_id, query, n_results: extension_settings.chromadb.n_results }),
@ -366,7 +366,7 @@ async function onSelectInjectFile(e) {
const url = new URL(getApiUrl());
url.pathname = '/api/chromadb';
const addMessagesResult = await fetch(url, {
const addMessagesResult = await doExtrasFetch(url, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify({ chat_id: currentChatId, messages: messages }),
@ -461,19 +461,20 @@ jQuery(async () => {
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<p>This extension rearranges the messages in the current chat to keep more relevant information in the context. Adjust the sliders below based on average amount of messages in your prompt (refer to the chat cut-off line).</p>
<span>Memory Injection Strategy</span>
<small>This extension rearranges the messages in the current chat to keep more relevant information in the context. Adjust the sliders below based on average amount of messages in your prompt (refer to the chat cut-off line).</small>
<span class="wide100p marginTopBot5 displayBlock">Memory Injection Strategy</span>
<hr>
<select id="chromadb_strategy">
<option value="original">Replace non-kept chat items with memories</option>
<option value="ross">Add memories after chat with a header tag</option>
</select>
<label for="chromadb_keep_context">How many original chat messages to keep: (<span id="chromadb_keep_context_value"></span>) messages</label>
<label for="chromadb_keep_context"><small>How many original chat messages to keep: (<span id="chromadb_keep_context_value"></span>) messages</small></label>
<input id="chromadb_keep_context" type="range" min="${defaultSettings.keep_context_min}" max="${defaultSettings.keep_context_max}" step="${defaultSettings.keep_context_step}" value="${defaultSettings.keep_context}" />
<label for="chromadb_n_results">Maximum number of ChromaDB 'memories' to inject: (<span id="chromadb_n_results_value"></span>) messages</label>
<label for="chromadb_n_results"><small>Maximum number of ChromaDB 'memories' to inject: (<span id="chromadb_n_results_value"></span>) messages</small></label>
<input id="chromadb_n_results" type="range" min="${defaultSettings.n_results_min}" max="${defaultSettings.n_results_max}" step="${defaultSettings.n_results_step}" value="${defaultSettings.n_results}" />
<label for="chromadb_split_length">Max length for each 'memory' pulled from the current chat history: (<span id="chromadb_split_length_value"></span>) characters</label>
<label for="chromadb_split_length"><small>Max length for each 'memory' pulled from the current chat history: (<span id="chromadb_split_length_value"></span>) characters</small></label>
<input id="chromadb_split_length" type="range" min="${defaultSettings.split_length_min}" max="${defaultSettings.split_length_max}" step="${defaultSettings.split_length_step}" value="${defaultSettings.split_length}" />
<label for="chromadb_file_split_length">Max length for each 'memory' pulled from imported text files: (<span id="chromadb_file_split_length_value"></span>) characters</label>
<label for="chromadb_file_split_length"><small>Max length for each 'memory' pulled from imported text files: (<span id="chromadb_file_split_length_value"></span>) characters</small></label>
<input id="chromadb_file_split_length" type="range" min="${defaultSettings.file_split_length_min}" max="${defaultSettings.file_split_length_max}" step="${defaultSettings.file_split_length_step}" value="${defaultSettings.file_split_length}" />
<label class="checkbox_label" for="chromadb_freeze" title="Pauses the automatic synchronization of new messages with ChromaDB. Older messages and injections will still be pulled as usual." >
<input type="checkbox" id="chromadb_freeze" />
@ -503,7 +504,7 @@ jQuery(async () => {
<form><input id="chromadb_import_file" type="file" accept="application/json" hidden></form>
</div>`;
$('#extensions_settings').append(settingsHtml);
$('#extensions_settings2').append(settingsHtml);
$('#chromadb_strategy').on('change', onStrategyChange);
$('#chromadb_keep_context').on('input', onKeepContextInput);
$('#chromadb_n_results').on('input', onNResultsInput);

View File

@ -1,5 +1,5 @@
import { getStringHash, debounce } from "../../utils.js";
import { getContext, getApiUrl, extension_settings, ModuleWorkerWrapper } from "../../extensions.js";
import { getContext, getApiUrl, extension_settings, ModuleWorkerWrapper, doExtrasFetch } from "../../extensions.js";
import { extension_prompt_types, is_send_press, saveSettingsDebounced } from "../../../script.js";
export { MODULE_NAME };
@ -232,7 +232,7 @@ async function summarizeChat(context) {
const url = new URL(getApiUrl());
url.pathname = '/api/summarize';
const apiResult = await fetch(url, {
const apiResult = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -321,24 +321,24 @@ $(document).ready(function () {
<div id="memory_settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b>Chat memory</b>
<b>Summarize</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<label for="memory_contents">Memory contents</label>
<label for="memory_contents">Current summary: </label>
<textarea id="memory_contents" class="text_pole" rows="8" placeholder="Context will be generated here..."></textarea>
<div class="memory_contents_controls">
<input id="memory_restore" class="menu_button" type="submit" value="Restore previous state" />
<label for="memory_frozen"><input id="memory_frozen" type="checkbox" /> Freeze context</label>
<label for="memory_frozen"><input id="memory_frozen" type="checkbox" />Stop summarization updates</label>
</div>
</div>
<!--</div>
</div>
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b>Summarization parameters</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<div class="inline-drawer-content">-->
<label for="memory_short_length">Buffer <small>[short-term]</small> length (<span id="memory_short_length_tokens"></span> tokens)</label>
<input id="memory_short_length" type="range" value="${defaultSettings.shortMemoryLength}" min="${defaultSettings.minShortMemory}" max="${defaultSettings.maxShortMemory}" step="${defaultSettings.shortMemoryStep}" />
<label for="memory_long_length">Summary <small>[long-term]</small> length (<span id="memory_long_length_tokens"></span> tokens)</label>
@ -353,7 +353,7 @@ $(document).ready(function () {
</div>
</div>
`;
$('#extensions_settings').append(settingsHtml);
$('#extensions_settings2').append(settingsHtml);
$('#memory_restore').on('click', onMemoryRestoreClick);
$('#memory_contents').on('input', onMemoryContentInput);
$('#memory_long_length').on('input', onMemoryLongInput);

View File

@ -66,7 +66,6 @@ function addTask(description, index = null) {
saveState()
}
// Return the task and index or throw an error
function getTaskById(taskId){
if (taskId == null) {
@ -77,11 +76,13 @@ function getTaskById(taskId){
return { task: globalTasks[index], index: index };
} else {
throw `Cannot find task with ${taskId}`
}
}
function deleteTask(taskId){
const { task, index } = getTaskById(taskId)
globalTasks.splice(index, 1)
setCurrentTask()
updateUiTaskList()
@ -325,6 +326,7 @@ function debugObjectiveExtension() {
window.debugObjectiveExtension = debugObjectiveExtension
// Populate UI task list
function updateUiTaskList() {
$('#objective-tasks').empty()
@ -334,7 +336,7 @@ function updateUiTaskList() {
task.addUiElement()
}
} else {
// Show button to add tasks if there are none
// Show button to add tasks if there are none
$('#objective-tasks').append(`
<input id="objective-task-add-first" type="button" class="menu_button" value="Add Task">
`)
@ -362,7 +364,7 @@ function onChatDepthInput() {
// Update how often we check for task completion
function onCheckFrequencyInput() {
checkCounter = $("#objective-check-frequency").val()
checkCounter = $("#objective-check-frequency").val()
$('#objective-counter').text(checkCounter)
saveState()
}
@ -434,20 +436,20 @@ jQuery(() => {
<div class="inline-drawer-content">
<label for="objective-text"><small>Enter an objective and generate tasks. The AI will attempt to complete tasks autonomously</small></label>
<textarea id="objective-text" type="text" class="text_pole textarea_compact" rows="4"></textarea>
<div class="objective_block">
<input id="objective-generate" class="menu_button" type="submit" value="Generate Tasks" />
<small>Automatically generate tasks for Objective. Takes a moment.</small>
<div class="objective_block flex-container">
<input id="objective-generate" class="menu_button" type="submit" value="Auto-Generate Tasks" />
<label class="checkbox_label"><input id="objective-hide-tasks" type="checkbox"> Hide Tasks</label>
</div>
</br>
<label class="checkbox_label"><input id="objective-hide-tasks" type="checkbox"> Hide Tasks</label><br>
<div id="objective-tasks"> </div>
<div class="objective_block">
<div class="objective_block objective_block_control flex1">
<label for="objective-chat-depth">In-chat @ Depth</label>
<div class="objective_block margin-bot-10px">
<div class="objective_block objective_block_control flex1 flexFlowColumn">
<label for="objective-chat-depth">Position in Chat</label>
<input id="objective-chat-depth" class="text_pole widthUnset" type="number" min="0" max="99" />
</div>
<br>
<div class="objective_block objective_block_control flex1">
<label for="objective-check-frequency">Task Check Frequency</label>
<input id="objective-check-frequency" class="text_pole widthUnset" type="number" min="0" max="99" />
<small>(0 = disabled)</small>

View File

@ -5,9 +5,10 @@
.objective_block {
display: flex;
flex-direction: row;
/* flex-direction: row; */
align-items: center;
column-gap: 10px;
column-gap: 5px;
flex-wrap: wrap;
}
.objective_block_control small,
@ -24,7 +25,7 @@
opacity: 0.7;
align-items: center;
justify-content: center;
}
.objective-task-button:hover {
@ -33,4 +34,9 @@
[id^=objective-task-delete-] {
color: #da3f3f;
}
#objective-tasks span {
margin: unset;
margin-bottom: 5px !important;
}

View File

@ -121,30 +121,30 @@ jQuery(async () => {
Enable Quick Replies
</label>
<small><i>Customize your Quick Replies:</i></small><br>
<div class="flex-container">
<input class="text_pole widthUnset" id="quickReply1Label" placeholder="(Add a button label)">
<div class="flex-container alignitemsflexstart">
<input class="text_pole wide30p" id="quickReply1Label" placeholder="(Add a button label)">
<textarea id="quickReply1Mes" placeholder="(custom message here)" class="text_pole textarea_compact widthUnset flex1" rows="2"></textarea>
</div>
<div class="flex-container">
<input class="text_pole widthUnset" id="quickReply2Label" placeholder="(Add a button label)">
<div class="flex-container alignitemsflexstart">
<input class="text_pole wide30p" id="quickReply2Label" placeholder="(Add a button label)">
<textarea id="quickReply2Mes" placeholder="(custom message here)" class="text_pole textarea_compact widthUnset flex1" rows="2"></textarea>
</div>
<div class="flex-container">
<input class="text_pole widthUnset" id="quickReply3Label" placeholder="(Add a button label)">
<div class="flex-container alignitemsflexstart">
<input class="text_pole wide30p" id="quickReply3Label" placeholder="(Add a button label)">
<textarea id="quickReply3Mes" placeholder="(custom message here)" class="text_pole textarea_compact widthUnset flex1" rows="2"></textarea>
</div>
<div class="flex-container">
<input class="text_pole widthUnset" id="quickReply4Label" placeholder="(Add a button label)">
<div class="flex-container alignitemsflexstart">
<input class="text_pole wide30p" id="quickReply4Label" placeholder="(Add a button label)">
<textarea id="quickReply4Mes" placeholder="(custom message here)" class="text_pole textarea_compact widthUnset flex1" rows="2"></textarea>
</div>
<div class="flex-container">
<input class="text_pole widthUnset" id="quickReply5Label" placeholder="(Add a button label)">
<div class="flex-container alignitemsflexstart">
<input class="text_pole wide30p" id="quickReply5Label" placeholder="(Add a button label)">
<textarea id="quickReply5Mes" placeholder="(custom message here)" class="text_pole textarea_compact widthUnset flex1" rows="2"></textarea>
</div>
</div>
</div>`;
$('#extensions_settings').append(settingsHtml);
$('#extensions_settings2').append(settingsHtml);
$('#quickReply1Mes').on('input', function () { onQuickReplyInput(1); });
$('#quickReply2Mes').on('input', function () { onQuickReplyInput(2); });

View File

@ -41,7 +41,4 @@
opacity: 1;
filter: brightness(1.2);
cursor: pointer;
}

View File

@ -0,0 +1,110 @@
// Borrowed from Agnai (AGPLv3)
// https://github.com/agnaistic/agnai/blob/dev/web/pages/Chat/components/SpeechRecognitionRecorder.tsx
function capitalizeInterim(interimTranscript) {
let capitalizeIndex = -1;
if (interimTranscript.length > 2 && interimTranscript[0] === ' ') capitalizeIndex = 1;
else if (interimTranscript.length > 1) capitalizeIndex = 0;
if (capitalizeIndex > -1) {
const spacing = capitalizeIndex > 0 ? ' '.repeat(capitalizeIndex - 1) : '';
const capitalized = interimTranscript[capitalizeIndex].toLocaleUpperCase();
const rest = interimTranscript.substring(capitalizeIndex + 1);
interimTranscript = spacing + capitalized + rest;
}
return interimTranscript;
}
function composeValues(previous, interim) {
let spacing = '';
if (previous.endsWith('.')) spacing = ' ';
return previous + spacing + interim;
}
(function ($) {
$.fn.speechRecognitionPlugin = function (options) {
const settings = $.extend({
grammar: '' // Custom grammar
}, options);
const speechRecognition = window.SpeechRecognition || webkitSpeechRecognition;
const speechRecognitionList = window.SpeechGrammarList || webkitSpeechGrammarList;
if (!speechRecognition) {
console.warn('Speech recognition is not supported in this browser.');
return;
}
const recognition = new speechRecognition();
if (settings.grammar) {
speechRecognitionList.addFromString(settings.grammar, 1);
recognition.grammars = speechRecognitionList;
}
recognition.continuous = true;
recognition.interimResults = true;
// TODO: This should be configurable.
recognition.lang = 'en-US'; // Set the language to English (US).
const $textarea = this;
const $button = $('<div class="fa-solid fa-microphone speech-toggle" title="Click to speak"></div>');
$('#send_but_sheld').prepend($button);
let listening = false;
$button.on('click', function () {
if (listening) {
recognition.stop();
} else {
recognition.start();
}
listening = !listening;
});
let initialText = '';
recognition.onresult = function (speechEvent) {
let finalTranscript = '';
let interimTranscript = ''
for (let i = speechEvent.resultIndex; i < speechEvent.results.length; ++i) {
const transcript = speechEvent.results[i][0].transcript;
if (speechEvent.results[i].isFinal) {
let interim = capitalizeInterim(transcript);
if (interim != '') {
let final = finalTranscript;
final = composeValues(final, interim) + '.';
finalTranscript = final;
recognition.abort();
listening = false;
}
interimTranscript = ' ';
} else {
interimTranscript += transcript;
}
}
interimTranscript = capitalizeInterim(interimTranscript);
$textarea.val(initialText + finalTranscript + interimTranscript);
};
recognition.onerror = function (event) {
console.error('Error occurred in recognition:', event.error);
};
recognition.onend = function () {
listening = false;
$button.toggleClass('fa-microphone fa-microphone-slash');
};
recognition.onstart = function () {
initialText = $textarea.val();
$button.toggleClass('fa-microphone fa-microphone-slash');
};
};
}(jQuery));
jQuery(() => {
const $textarea = $('#send_textarea');
$textarea.speechRecognitionPlugin();
});

View File

@ -0,0 +1,11 @@
{
"display_name": "Speech Recognition",
"loading_order": 13,
"requires": [],
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "Cohee#1207",
"version": "1.0.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}

View File

@ -0,0 +1,3 @@
.speech-toggle {
display: flex;
}

View File

@ -10,7 +10,7 @@ import {
eventSource,
appendImageToMessage
} from "../../../script.js";
import { getApiUrl, getContext, extension_settings, defaultRequestArgs, modules } from "../../extensions.js";
import { getApiUrl, getContext, extension_settings, doExtrasFetch, modules } from "../../extensions.js";
import { stringFormat, initScrollHeight, resetScrollHeight } from "../../utils.js";
export { MODULE_NAME };
@ -234,7 +234,7 @@ async function onModelChange() {
async function updateExtrasRemoteModel() {
const url = new URL(getApiUrl());
url.pathname = '/api/image/model';
const getCurrentModelResult = await fetch(url, {
const getCurrentModelResult = await doExtrasFetch(url, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify({ model: extension_settings.sd.model }),
@ -285,7 +285,7 @@ async function loadExtrasSamplers() {
const url = new URL(getApiUrl());
url.pathname = '/api/image/samplers';
const result = await fetch(url, defaultRequestArgs);
const result = await doExtrasFetch(url);
if (result.ok) {
const data = await result.json();
@ -338,7 +338,7 @@ async function loadExtrasModels() {
const url = new URL(getApiUrl());
url.pathname = '/api/image/model';
const getCurrentModelResult = await fetch(url, defaultRequestArgs);
const getCurrentModelResult = await doExtrasFetch(url);
if (getCurrentModelResult.ok) {
const data = await getCurrentModelResult.json();
@ -346,7 +346,7 @@ async function loadExtrasModels() {
}
url.pathname = '/api/image/models';
const getModelsResult = await fetch(url, defaultRequestArgs);
const getModelsResult = await doExtrasFetch(url);
if (getModelsResult.ok) {
const data = await getModelsResult.json();
@ -493,7 +493,7 @@ async function generateExtrasImage(prompt, callback) {
console.log(extension_settings.sd);
const url = new URL(getApiUrl());
url.pathname = '/api/image';
const result = await fetch(url, {
const result = await doExtrasFetch(url, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify({
@ -633,11 +633,11 @@ function isConnectedToExtras() {
async function moduleWorker() {
if (isConnectedToExtras() || extension_settings.sd.horde) {
$('#sd_gen').show(200);
$('#sd_gen').show();
$('.sd_message_gen').show();
}
else {
$('#sd_gen').hide(200);
$('#sd_gen').hide();
$('.sd_message_gen').hide();
}
}

View File

@ -350,7 +350,7 @@ jQuery(() => {
Translate Chat
</div>`;
$('#extensionsMenu').append(buttonHtml);
$('#extensions_settings').append(html);
$('#extensions_settings2').append(html);
$('#translate_chat').on('click', onTranslateChatClick);
$('#translation_clear').on('click', onTranslationsClearClick);

View File

@ -0,0 +1,142 @@
import { getRequestHeaders } from "../../../script.js"
import { getApiUrl } from "../../extensions.js"
import { doExtrasFetch, modules } from "../../extensions.js"
import { getPreviewString } from "./index.js"
export { EdgeTtsProvider }
class EdgeTtsProvider {
//########//
// Config //
//########//
settings
voices = []
separator = ' . '
audioElement = document.createElement('audio')
defaultSettings = {
voiceMap: {}
}
get settingsHtml() {
let html = `Microsoft Edge TTS Provider<br>`
return html
}
onSettingsChange() {
}
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}`
}
}
console.info("Settings loaded")
}
async onApplyClick() {
return
}
//#################//
// TTS Interfaces //
//#################//
async getVoice(voiceName) {
if (this.voices.length == 0) {
this.voices = await this.fetchTtsVoiceIds()
}
const match = this.voices.filter(
voice => voice.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 fetchTtsVoiceIds() {
throwIfModuleMissing()
const url = new URL(getApiUrl());
url.pathname = `/api/edge-tts/list`
const response = await doExtrasFetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
let responseJson = await response.json()
responseJson = responseJson
.sort((a, b) => a.Locale.localeCompare(b.Locale) || a.ShortName.localeCompare(b.ShortName))
.map(x => ({ name: x.ShortName, voice_id: x.ShortName, preview_url: false, lang: x.Locale }));
return responseJson
}
async previewTtsVoice(id) {
this.audioElement.pause();
this.audioElement.currentTime = 0;
const voice = await this.getVoice(id);
const text = getPreviewString(voice.lang);
const response = await this.fetchTtsGeneration(text, id)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
const audio = await response.blob();
const url = URL.createObjectURL(audio);
this.audioElement.src = url;
this.audioElement.play();
}
async fetchTtsGeneration(inputText, voiceId) {
throwIfModuleMissing()
console.info(`Generating new TTS for voice_id ${voiceId}`)
const url = new URL(getApiUrl());
url.pathname = `/api/edge-tts/generate`;
const response = await doExtrasFetch(url,
{
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
"text": inputText,
"voice": voiceId,
})
}
)
if (!response.ok) {
toastr.error(response.statusText, 'TTS Generation Failed');
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response
}
}
function throwIfModuleMissing() {
if (!modules.includes('edge-tts')) {
toastr.error(`Edge TTS module not loaded. Add edge-tts to enable-modules and restart the Extras API.`)
throw new Error(`Edge TTS module not loaded.`)
}
}

View File

@ -8,7 +8,7 @@ class ElevenLabsTtsProvider {
settings
voices = []
separator = ' ... ... ... '
get settings() {
return this.settings
}
@ -43,7 +43,7 @@ class ElevenLabsTtsProvider {
this.settings.similarity_boost = $('#elevenlabs_tts_similarity_boost').val()
this.settings.multilingual = $('#elevenlabs_tts_multilingual').prop('checked')
}
loadSettings(settings) {
// Pupulate Provider UI given input settings
@ -75,8 +75,8 @@ class ElevenLabsTtsProvider {
throw error
})
}
async updateApiKey() {
// Using this call to validate API key
this.settings.apiKey = $('#elevenlabs_tts_api_key').val()
@ -108,7 +108,7 @@ class ElevenLabsTtsProvider {
async generateTts(text, voiceId){
const historyId = await this.findTtsGenerationInHistory(text, voiceId)
let response
if (historyId) {
console.debug(`Found existing TTS generation with id ${historyId}`)
@ -119,11 +119,11 @@ class ElevenLabsTtsProvider {
}
return response
}
//###################//
// Helper Functions //
//###################//
async findTtsGenerationInHistory(message, voiceId) {
const ttsHistory = await this.fetchTtsHistory()
for (const history of ttsHistory) {
@ -149,7 +149,7 @@ class ElevenLabsTtsProvider {
headers: headers
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
const responseJson = await response.json()
return responseJson.voices
@ -166,7 +166,7 @@ class ElevenLabsTtsProvider {
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
return response.json()
}
@ -193,7 +193,8 @@ class ElevenLabsTtsProvider {
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
toastr.error(response.statusText, 'TTS Generation Failed');
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response
}
@ -209,7 +210,7 @@ class ElevenLabsTtsProvider {
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
return response
}
@ -222,7 +223,7 @@ class ElevenLabsTtsProvider {
headers: headers
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
const responseJson = await response.json()
return responseJson.history

View File

@ -1,9 +1,11 @@
import { callPopup, cancelTtsPlay, eventSource, event_types, isMultigenEnabled, is_send_press, saveSettingsDebounced } from '../../../script.js'
import { ModuleWorkerWrapper, extension_settings, getContext } from '../../extensions.js'
import { getStringHash } from '../../utils.js'
import { EdgeTtsProvider } from './edge.js'
import { ElevenLabsTtsProvider } from './elevenlabs.js'
import { SileroTtsProvider } from './silerotts.js'
import { SystemTtsProvider } from './system.js'
import { NovelTtsProvider } from './novel.js'
const UPDATE_INTERVAL = 1000
@ -15,11 +17,53 @@ let lastGroupId = null
let lastChatId = null
let lastMessageHash = null
export function getPreviewString(lang) {
const previewStrings = {
'en-US': 'The quick brown fox jumps over the lazy dog',
'en-GB': 'Sphinx of black quartz, judge my vow',
'fr-FR': 'Portez ce vieux whisky au juge blond qui fume',
'de-DE': 'Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich',
'it-IT': "Pranzo d'acqua fa volti sghembi",
'es-ES': 'Quiere la boca exhausta vid, kiwi, piña y fugaz jamón',
'es-MX': 'Fabio me exige, sin tapujos, que añada cerveza al whisky',
'ru-RU': 'В чащах юга жил бы цитрус? Да, но фальшивый экземпляр!',
'pt-BR': 'Vejo xá gritando que fez show sem playback.',
'pt-PR': 'Todo pajé vulgar faz boquinha sexy com kiwi.',
'uk-UA': "Фабрикуймо гідність, лящім їжею, ґав хапаймо, з'єднавці чаш!",
'pl-PL': 'Pchnąć w tę łódź jeża lub ośm skrzyń fig',
'cs-CZ': 'Příliš žluťoučký kůň úpěl ďábelské ódy',
'sk-SK': 'Vyhŕňme si rukávy a vyprážajme čínske ryžové cestoviny',
'hu-HU': 'Árvíztűrő tükörfúrógép',
'tr-TR': 'Pijamalı hasta yağız şoföre çabucak güvendi',
'nl-NL': 'De waard heeft een kalfje en een pinkje opgegeten',
'sv-SE': 'Yxskaftbud, ge vårbygd, zinkqvarn',
'da-DK': 'Quizdeltagerne spiste jordbær med fløde, mens cirkusklovnen Walther spillede på xylofon',
'ja-JP': 'いろはにほへと ちりぬるを わかよたれそ つねならむ うゐのおくやま けふこえて あさきゆめみし ゑひもせす',
'ko-KR': '가나다라마바사아자차카타파하',
'zh-CN': '我能吞下玻璃而不伤身体',
'ro-RO': 'Muzicologă în bej vând whisky și tequila, preț fix',
'bg-BG': 'Щъркелите се разпръснаха по цялото небе',
'el-GR': 'Ταχίστη αλώπηξ βαφής ψημένη γη, δρασκελίζει υπέρ νωθρού κυνός',
'fi-FI': 'Voi veljet, miksi juuri teille myin nämä vehkeet?',
'he-IL': 'הקצינים צעקו: "כל הכבוד לצבא הצבאות!"',
'id-ID': 'Jangkrik itu memang enak, apalagi kalau digoreng',
'ms-MY': 'Muzik penyanyi wanita itu menggambarkan kehidupan yang penuh dengan duka nestapa',
'th-TH': 'เป็นไงบ้างครับ ผมชอบกินข้าวผัดกระเพราหมูกรอบ',
'vi-VN': 'Cô bé quàng khăn đỏ đang ngồi trên bãi cỏ xanh',
'ar-SA': 'أَبْجَدِيَّة عَرَبِيَّة',
'hi-IN': 'श्वेता ने श्वेता के श्वेते हाथों में श्वेता का श्वेता चावल पकड़ा',
}
const fallbackPreview = 'Neque porro quisquam est qui dolorem ipsum quia dolor sit amet'
return previewStrings[lang] ?? fallbackPreview;
}
let ttsProviders = {
ElevenLabs: ElevenLabsTtsProvider,
Silero: SileroTtsProvider,
System: SystemTtsProvider,
Edge: EdgeTtsProvider,
Novel: NovelTtsProvider,
}
let ttsProvider
let ttsProviderName
@ -202,7 +246,7 @@ async function playAudioData(audioBlob) {
window['tts_preview'] = function (id) {
const audio = document.getElementById(id)
if (!$(audio).data('disabled')) {
if (audio && !$(audio).data('disabled')) {
audio.play()
}
else {
@ -223,7 +267,9 @@ async function onTtsVoicesClick() {
<b class="voice_name">${voice.name}</b>
<i onclick="tts_preview('${voice.voice_id}')" class="fa-solid fa-play"></i>
</div>`
popupText += `<audio id="${voice.voice_id}" src="${voice.preview_url}" data-disabled="${voice.preview_url == false}"></audio>`
if (voice.preview_url) {
popupText += `<audio id="${voice.voice_id}" src="${voice.preview_url}" data-disabled="${voice.preview_url == false}"></audio>`
}
}
} catch {
popupText = 'Could not load voices list. Check your API key.'
@ -285,7 +331,7 @@ function completeCurrentAudioJob() {
*/
async function addAudioJob(response) {
const audioData = await response.blob()
if (!audioData.type in ['audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/wave']) {
if (!audioData.type in ['audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/webm']) {
throw `TTS received HTTP response with invalid data format. Expecting audio/mpeg, got ${audioData.type}`
}
audioJobQueue.push(audioData)
@ -372,6 +418,7 @@ async function processTtsQueue() {
const voice = await ttsProvider.getVoice((voiceMap[char]))
const voiceId = voice.voice_id
if (voiceId == null) {
toastr.error(`Specified voice for ${char} was not found. Check the TTS extension settings.`)
throw `Unable to attain voiceId for ${char}`
}
tts(text, voiceId)
@ -452,7 +499,6 @@ async function voicemapIsValid(parsedVoiceMap) {
async function updateVoiceMap() {
let isValidResult = false
const context = getContext()
const value = $('#tts_voice_map').val()
const parsedVoiceMap = parseVoiceMap(value)

View File

@ -0,0 +1,130 @@
import { getRequestHeaders } from "../../../script.js"
import { getPreviewString } from "./index.js"
export { NovelTtsProvider }
class NovelTtsProvider {
//########//
// Config //
//########//
settings
voices = []
separator = ' . '
audioElement = document.createElement('audio')
defaultSettings = {
voiceMap: {}
}
get settingsHtml() {
let html = `Use NovelAI's TTS engine.<br>
The Voice IDs in the preview list are only examples, as it can be any string of text. Feel free to try different options!<br>
<small><i>Hint: Save an API key in the NovelAI API settings to use it here.</i></small>`;
return html;
}
onSettingsChange() {
}
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}`
}
}
console.info("Settings loaded")
}
async onApplyClick() {
return
}
//#################//
// TTS Interfaces //
//#################//
async getVoice(voiceName) {
if (!voiceName) {
throw `TTS Voice name not provided`
}
return { name: voiceName, voice_id: voiceName, lang: 'en-US', preview_url: false}
}
async generateTts(text, voiceId) {
const response = await this.fetchTtsGeneration(text, voiceId)
return response
}
//###########//
// API CALLS //
//###########//
async fetchTtsVoiceIds() {
const voices = [
{ name: 'Ligeia', voice_id: 'Ligeia', lang: 'en-US', preview_url: false },
{ name: 'Aini', voice_id: 'Aini', lang: 'en-US', preview_url: false },
{ name: 'Orea', voice_id: 'Orea', lang: 'en-US', preview_url: false },
{ name: 'Claea', voice_id: 'Claea', lang: 'en-US', preview_url: false },
{ name: 'Lim', voice_id: 'Lim', lang: 'en-US', preview_url: false },
{ name: 'Aurae', voice_id: 'Aurae', lang: 'en-US', preview_url: false },
{ name: 'Naia', voice_id: 'Naia', lang: 'en-US', preview_url: false },
{ name: 'Aulon', voice_id: 'Aulon', lang: 'en-US', preview_url: false },
{ name: 'Elei', voice_id: 'Elei', lang: 'en-US', preview_url: false },
{ name: 'Ogma', voice_id: 'Ogma', lang: 'en-US', preview_url: false },
{ name: 'Raid', voice_id: 'Raid', lang: 'en-US', preview_url: false },
{ name: 'Pega', voice_id: 'Pega', lang: 'en-US', preview_url: false },
{ name: 'Lam', voice_id: 'Lam', lang: 'en-US', preview_url: false },
];
return voices;
}
async previewTtsVoice(id) {
this.audioElement.pause();
this.audioElement.currentTime = 0;
const text = getPreviewString('en-US')
const response = await this.fetchTtsGeneration(text, id)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const audio = await response.blob();
const url = URL.createObjectURL(audio);
this.audioElement.src = url;
this.audioElement.play();
}
async fetchTtsGeneration(inputText, voiceId) {
console.info(`Generating new TTS for voice_id ${voiceId}`)
const response = await fetch(`/novel_tts`,
{
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
"text": inputText,
"voice": voiceId,
})
}
)
if (!response.ok) {
toastr.error(response.statusText, 'TTS Generation Failed');
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response
}
}

View File

@ -1,4 +1,4 @@
import { getApiUrl, modules } from "../../extensions.js"
import { doExtrasFetch, getApiUrl, modules } from "../../extensions.js"
export { SileroTtsProvider }
@ -94,7 +94,7 @@ class SileroTtsProvider {
// API CALLS //
//###########//
async fetchTtsVoiceIds() {
const response = await fetch(`${this.settings.provider_endpoint}/speakers`)
const response = await doExtrasFetch(`${this.settings.provider_endpoint}/speakers`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
}
@ -104,7 +104,7 @@ class SileroTtsProvider {
async fetchTtsGeneration(inputText, voiceId) {
console.info(`Generating new TTS for voice_id ${voiceId}`)
const response = await fetch(
const response = await doExtrasFetch(
`${this.settings.provider_endpoint}/generate`,
{
method: 'POST',
@ -118,25 +118,15 @@ class SileroTtsProvider {
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
toastr.error(response.statusText, 'TTS Generation Failed');
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response
}
// Interface not used by Silero TTS
async fetchTtsFromHistory(history_item_id) {
console.info(`Fetched existing TTS with history_item_id ${history_item_id}`)
const response = await fetch(
`https://api.elevenlabs.io/v1/history/${history_item_id}/audio`,
{
headers: {
'xi-api-key': this.API_KEY
}
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.json()}`)
}
return response
return Promise.resolve(history_item_id);
}
}

View File

@ -1,3 +1,5 @@
import { getPreviewString } from "./index.js";
export { SystemTtsProvider }
/**
@ -74,20 +76,6 @@ class SystemTtsProvider {
// Config //
//########//
previewStrings = {
'en-US': 'The quick brown fox jumps over the lazy dog',
'en-GB': 'Sphinx of black quartz, judge my vow',
'fr-FR': 'Portez ce vieux whisky au juge blond qui fume',
'de-DE': 'Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich',
'it-IT': "Pranzo d'acqua fa volti sghembi",
'es-ES': 'Quiere la boca exhausta vid, kiwi, piña y fugaz jamón',
'es-MX': 'Fabio me exige, sin tapujos, que añada cerveza al whisky',
'ru-RU': 'В чащах юга жил бы цитрус? Да, но фальшивый экземпляр!',
'pt-BR': 'Vejo xá gritando que fez show sem playback.',
'pt-PR': 'Todo pajé vulgar faz boquinha sexy com kiwi.',
'uk-UA': "Фабрикуймо гідність, лящім їжею, ґав хапаймо, з'єднавці чаш!",
}
fallbackPreview = 'Neque porro quisquam est qui dolorem ipsum quia dolor sit amet'
settings
voices = []
separator = ' ... '
@ -172,7 +160,7 @@ class SystemTtsProvider {
}
speechSynthesis.cancel();
const text = this.previewStrings[voice.lang] ?? this.fallbackPreview;
const text = getPreviewString(voice.lang);
const utterance = new SpeechSynthesisUtterance(text);
utterance.voice = voice;
utterance.rate = 1;