Compare commits

..

21 Commits
1.6.2 ... 1.6.3

Author SHA1 Message Date
SillyLossy
83c875d8dc Properly position typing indicator after user message #423 2023-06-01 10:13:49 +03:00
SillyLossy
72b7b7cab2 Merge branch 'main' of https://github.com/SillyTavern/SillyTavern 2023-06-01 10:03:35 +03:00
RossAscends
55f38f69d6 fix new char highlight, group drawers autoOpen logic 2023-06-01 10:03:08 +03:00
SillyLossy
0633d16622 Fix typing indicator not showing in group chats on NovelAI 2023-06-01 10:01:43 +03:00
SillyLossy
35cb1f6182 Fix stop button not showing for the second speaking member in queue 2023-06-01 10:01:43 +03:00
SillyLossy
a18c20305e Clarify Chroma warning message 2023-06-01 10:01:43 +03:00
SillyLossy
d542ec0d81 Add the warning when ChromaDB synced message deletes 2023-06-01 10:01:43 +03:00
SillyLossy
6ad0be9597 Fix being unable to rewrite an existing bookmark 2023-06-01 10:01:43 +03:00
SillyLossy
0de09e9da0 Fix System TTS ending abruptly in Chrome on Windows 2023-06-01 10:01:43 +03:00
SillyLossy
bb187d9920 Proper chronological order of ChromaDB chat injections 2023-06-01 10:01:11 +03:00
SillyLossy
711dbdcc15 [Feature Request] Chromadb, ability to pause collection. SillyTavern/SillyTavern#420 2023-06-01 10:01:11 +03:00
Cohee
5215e6e437 Merge pull request #421 from ramblingcoder/main
Added "worlds" to dockerfile and changed cohee1207 to sillytavern in docker image
2023-06-01 09:53:12 +03:00
ramblingcoder
01c27bc9a9 Update docker-compose.yml 2023-05-31 17:55:26 -05:00
ramblingcoder
b35d8a4324 Added worlds to dockerfile 2023-05-31 17:54:59 -05:00
Cohee
6c6f5b7f1a Merge pull request #416 from BlipRanger/patch-2
Updated UI message about chromadb persistence
2023-05-31 21:46:46 +03:00
Cohee
cff5cd0928 Update index.js 2023-05-31 21:46:13 +03:00
RossAscends
fb1b02571e UpdateAndStart.bat notification for zip installs 2023-05-31 19:08:02 +09:00
SillyLossy
412fad002d #418 Fix freeze on group with all disabled. Allow to send user messages into group with all disabled. 2023-05-31 11:36:00 +03:00
SillyLossy
6ad2492ef6 Fix TTS worker console spam in empty chat 2023-05-31 10:56:25 +03:00
Cohee
d3b0ba02b6 Update readme.md 2023-05-31 10:21:57 +03:00
BlipRanger
d80fff3b5e Updated UI message about chromadb persistence 2023-05-30 19:47:55 -04:00
11 changed files with 194 additions and 60 deletions

View File

@@ -23,7 +23,7 @@ COPY . ./
# Copy default chats, characters and user avatars to <folder>.default folder
RUN \
IFS="," RESOURCES="characters,chats,groups,group chats,User Avatars,settings.json" && \
IFS="," RESOURCES="characters,chats,groups,group chats,User Avatars,worlds,settings.json" && \
\
echo "*** Store default $RESOURCES in <folder>.default ***" && \
for R in $RESOURCES; do mv "public/$R" "public/$R.default"; done && \

View File

@@ -3,6 +3,7 @@ pushd %~dp0
git --version > nul 2>&1
if %errorlevel% neq 0 (
echo Git is not installed on this system. Skipping update.
echo If you installed with a zip file, you will need to download the new zip and install it manually.
) else (
call git pull --rebase --autostash
if %errorlevel% neq 0 (

View File

@@ -4,7 +4,7 @@ services:
build: ..
container_name: sillytavern
hostname: sillytavern
image: cohee1207/sillytavern:latest
image: sillytavern/sillytavern:latest
ports:
- "8000:8000"
volumes:

View File

@@ -2512,7 +2512,7 @@ async function Generate(type, { automatic_trigger, force_name2, resolve, reject,
//console.log('generate ending');
} //generate ends
function getBiasStrings(textareaText) {
export function getBiasStrings(textareaText) {
let promptBias = '';
let messageBias = extractMessageBias(textareaText);
@@ -2551,7 +2551,7 @@ export function replaceBiasMarkup(str) {
return (str ?? '').replace(/{{(\*?.*\*?)}}/g, '');
}
async function sendMessageAsUser(textareaText, messageBias) {
export async function sendMessageAsUser(textareaText, messageBias) {
chat[chat.length] = {};
chat[chat.length - 1]['name'] = name1;
chat[chat.length - 1]['is_user'] = true;
@@ -3294,14 +3294,14 @@ export function isMultigenEnabled() {
return power_user.multigen && (main_api == 'textgenerationwebui' || main_api == 'kobold' || main_api == 'koboldhorde' || main_api == 'novel');
}
function activateSendButtons() {
export function activateSendButtons() {
is_send_press = false;
$("#send_but").css("display", "flex");
$("#send_textarea").attr("disabled", false);
hideStopButton();
}
function deactivateSendButtons() {
export function deactivateSendButtons() {
$("#send_but").css("display", "none");
showStopButton();
}
@@ -4248,27 +4248,40 @@ function select_rm_info(type, charId, previousCharId = null) {
getCharacters();
selectRightMenuWithAnimation('rm_characters_block');
if (type === 'char_import' || type === 'char_create') {
setTimeout(function () {
if (type === 'char_import' || type === 'char_create') {
const element = $(`#rm_characters_block [title="${charId}"]`).parent().get(0);
console.log(element);
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
const element = $(`#rm_characters_block [title="${charId}"]`).get(0);
element.scrollIntoView({ behavior: 'smooth', block: 'end' });
$(`#rm_characters_block [title="${charId}"]`).parent().addClass('flash animated');
setTimeout(function () {
$(`#rm_characters_block [title="${charId}"]`).parent().removeClass('flash animated');
}, 5000);
}
if (type === 'group_create') {
//for groups, ${charId} = data.id from group-chats.js createGroup()
const element = $(`#rm_characters_block [grid="${charId}"]`).get(0);
element.scrollIntoView({ behavior: 'smooth', block: 'end' });
$(`#rm_characters_block [grid="${charId}"]`).addClass('flash animated');
setTimeout(function () {
$(`#rm_characters_block [grid="${charId}"]`).removeClass('flash animated');
}, 5000);
}
try {
if (element !== undefined || element !== null) {
$(element).addClass('flash animated');
setTimeout(function () {
$(element).removeClass('flash animated');
}, 5000);
} else { console.log('didnt find the element'); }
} catch (e) {
console.error(e);
}
}
if (type === 'group_create') {
//for groups, ${charId} = data.id from group-chats.js createGroup()
const element = $(`#rm_characters_block [grid="${charId}"]`).get(0);
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
if (element !== undefined || element !== null) {
$(element).addClass('flash animated');
setTimeout(function () {
$(element).removeClass('flash animated');
}, 5000);
} else { console.log('didnt find the element'); }
} catch (e) {
console.error(e);
}
}
}, 100);
setRightTabSelectedClass();
if (previousCharId) {
@@ -5106,6 +5119,12 @@ function importCharacter(file) {
$(document).ready(function () {
//////////INPUT BAR FOCUS-KEEPING LOGIC/////////////
setTimeout(function () {
$("#groupControlsToggle").trigger('click');
$("#groupCurrentMemberListToggle .inline-drawer-icon").trigger('click');
}, 200);
$("#rm_print_characters_block").on('scroll',
debounce(updateVisibleDivs, 5));

View File

@@ -144,6 +144,7 @@ async function createNewBookmark() {
}
}
await delay(250);
let name = await getBookmarkName();
if (!name) {

View File

@@ -51,6 +51,22 @@ function getChatSyncState() {
const context = getContext();
const chatState = chatStateFlags[currentChatId] || [];
// if the chat length has decreased, it means that some messages were deleted
if (chatState.length > context.chat.length) {
for (let i = context.chat.length; i < chatState.length; i++) {
// if the synced message was deleted, notify the user
if (chatState[i]) {
toastr.warning(
'Purge your ChromaDB to remove it from there too. See the "Smart Context" tab in the Extensions menu for more information.',
'Message deleted from chat, but it still exists inside the ChromaDB database.',
{ timeOut: 0, extendedTimeOut: 0, preventDuplicates: true },
);
break;
}
}
}
chatState.length = context.chat.length;
for (let i = 0; i < chatState.length; i++) {
if (chatState[i] === undefined) {
@@ -76,6 +92,7 @@ async function loadSettings() {
$('#chromadb_n_results').val(extension_settings.chromadb.n_results).trigger('input');
$('#chromadb_split_length').val(extension_settings.chromadb.split_length).trigger('input');
$('#chromadb_file_split_length').val(extension_settings.chromadb.file_split_length).trigger('input');
$('#chromadb_freeze').prop('checked', extension_settings.chromadb.freeze);
}
function onStrategyChange() {
@@ -119,6 +136,10 @@ function checkChatId(chat_id) {
}
async function addMessages(chat_id, messages) {
if (extension_settings.chromadb.freeze) {
return { count: 0 };
}
const url = new URL(getApiUrl());
url.pathname = '/api/chromadb';
@@ -328,12 +349,13 @@ async function onSelectInjectFile(e) {
const text = await getFileText(file);
const split = splitRecursive(text, extension_settings.chromadb.file_split_length).filter(onlyUnique);
const baseDate = Date.now();
const messages = split.map(m => ({
const messages = split.map((m, i) => ({
id: `${file.name}-${split.indexOf(m)}`,
role: 'system',
content: m,
date: Date.now(),
date: baseDate + i,
meta: JSON.stringify({
name: file.name,
is_user: false,
@@ -380,7 +402,7 @@ window.chromadb_interceptGeneration = async (chat) => {
if (currentChatId) {
const messagesToStore = chat.slice(0, -extension_settings.chromadb.keep_context);
if (messagesToStore.length > 0) {
if (messagesToStore.length > 0 || extension_settings.chromadb.freeze) {
await addMessages(currentChatId, messagesToStore);
const lastMessage = chat[chat.length - 1];
@@ -431,6 +453,11 @@ window.chromadb_interceptGeneration = async (chat) => {
}
}
function onFreezeInput() {
extension_settings.chromadb.freeze = $('#chromadb_freeze').is(':checked');
saveSettingsDebounced();
}
jQuery(async () => {
const settingsHtml = `
<div class="chromadb_settings">
@@ -454,6 +481,10 @@ jQuery(async () => {
<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>
<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" />
<span>Freeze ChromaDB state</span>
</label>
<div class="flex-container spaceEvenly">
<div id="chromadb_inject" title="Upload custom textual data to use in the context of the current chat" class="menu_button">
<i class="fa-solid fa-file-arrow-up"></i>
@@ -472,7 +503,7 @@ jQuery(async () => {
<span>Purge Chat from the DB</span>
</div>
</div>
<small><i>Since ChromaDB state is not persisted to disk by default, you'll need to inject text data every time the Extras API server is restarted.</i></small>
<small><i>Local ChromaDB now persists to disk by default. The default folder is .chroma_db, and you can set a different folder with the --chroma-folder argument. If you are using the Extras Colab notebook, you will need to inject the text data every time the Extras API server is restarted.</i></small>
</div>
<form><input id="chromadb_inject_file" type="file" accept="text/plain" hidden></form>
<form><input id="chromadb_import_file" type="file" accept="application/json" hidden></form>
@@ -490,6 +521,7 @@ jQuery(async () => {
$('#chromadb_import_file').on('change', onSelectImportFile);
$('#chromadb_purge').on('click', onPurgeClick);
$('#chromadb_export').on('click', onExportClick);
$('#chromadb_freeze').on('input', onFreezeInput);
await loadSettings();
// Not sure if this is needed, but it's here just in case

View File

@@ -110,6 +110,7 @@ async function moduleWorker() {
// We're currently swiping or streaming. Don't generate voice
if (
!message ||
message.mes === '...' ||
message.mes === '' ||
(context.streamingProcessor && !context.streamingProcessor.isFinished)

View File

@@ -1,5 +1,74 @@
export { SystemTtsProvider }
/**
* Chunkify
* Google Chrome Speech Synthesis Chunking Pattern
* Fixes inconsistencies with speaking long texts in speechUtterance objects
* Licensed under the MIT License
*
* Peter Woolley and Brett Zamir
* Modified by Haaris for bug fixes
*/
var speechUtteranceChunker = function (utt, settings, callback) {
settings = settings || {};
var newUtt;
var txt = (settings && settings.offset !== undefined ? utt.text.substring(settings.offset) : utt.text);
if (utt.voice && utt.voice.voiceURI === 'native') { // Not part of the spec
newUtt = utt;
newUtt.text = txt;
newUtt.addEventListener('end', function () {
if (speechUtteranceChunker.cancel) {
speechUtteranceChunker.cancel = false;
}
if (callback !== undefined) {
callback();
}
});
}
else {
var chunkLength = (settings && settings.chunkLength) || 160;
var pattRegex = new RegExp('^[\\s\\S]{' + Math.floor(chunkLength / 2) + ',' + chunkLength + '}[.!?,]{1}|^[\\s\\S]{1,' + chunkLength + '}$|^[\\s\\S]{1,' + chunkLength + '} ');
var chunkArr = txt.match(pattRegex);
if (chunkArr == null || chunkArr[0] === undefined || chunkArr[0].length <= 2) {
//call once all text has been spoken...
if (callback !== undefined) {
callback();
}
return;
}
var chunk = chunkArr[0];
newUtt = new SpeechSynthesisUtterance(chunk);
var x;
for (x in utt) {
if (utt.hasOwnProperty(x) && x !== 'text') {
newUtt[x] = utt[x];
}
}
newUtt.lang = utt.lang;
newUtt.voice = utt.voice;
newUtt.addEventListener('end', function () {
if (speechUtteranceChunker.cancel) {
speechUtteranceChunker.cancel = false;
return;
}
settings.offset = settings.offset || 0;
settings.offset += chunk.length;
speechUtteranceChunker(utt, settings, callback);
});
}
if (settings.modifier) {
settings.modifier(newUtt);
}
console.log(newUtt); //IMPORTANT!! Do not remove: Logging the object out fixes some onend firing issues.
//placing the speak invocation inside a callback fixes ordering and onend issues.
setTimeout(function () {
speechSynthesis.speak(newUtt);
}, 0);
};
class SystemTtsProvider {
//########//
// Config //
@@ -142,7 +211,12 @@ class SystemTtsProvider {
utterance.pitch = this.settings.pitch || 1;
utterance.onend = () => resolve(silence);
utterance.onerror = () => reject();
speechSynthesis.speak(utterance);
speechUtteranceChunker(utterance, {
chunkLength: 200,
}, function () {
//some code to execute when done
console.log('System TTS done');
});
});
}
}

View File

@@ -48,6 +48,11 @@ import {
cancelTtsPlay,
isMultigenEnabled,
displayPastChats,
sendMessageAsUser,
getBiasStrings,
saveChatConditional,
deactivateSendButtons,
activateSendButtons,
} from "../script.js";
import { appendTagToList, createTagMapFromList, getTagsList, applyTagsOnCharacterSelect } from './tags.js';
@@ -414,7 +419,7 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
group_generation_id = Date.now();
const lastMessage = chat[chat.length - 1];
let messagesBefore = chat.length;
let lastMessageText = lastMessage.mes;
let lastMessageText = lastMessage?.mes || '';
let activationText = "";
let isUserInput = false;
let isGenerationDone = false;
@@ -491,17 +496,23 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
if (activatedMembers.length === 0) {
toastr.warning('All group members are disabled. Enable at least one to get a reply.');
throw new Error('All group members are disabled');
// Send user message as is
const bias = getBiasStrings(userInput);
await sendMessageAsUser(userInput, bias.messageBias);
await saveChatConditional();
$('#send_textarea').val('');
}
// now the real generation begins: cycle through every activated character
for (const chId of activatedMembers) {
deactivateSendButtons();
isGenerationDone = false;
const generateType = type == "swipe" || type == "impersonate" || type == "quiet" ? type : "group_chat";
setCharacterId(chId);
setCharacterName(characters[chId].name)
await Generate(generateType, { automatic_trigger: by_auto_mode, ...(params || {}) });
Generate(generateType, { automatic_trigger: by_auto_mode, ...(params || {}) });
if (type !== "swipe" && type !== "impersonate" && !isMultigenEnabled() && !isStreamingEnabled()) {
// update indicator and scroll down
@@ -509,13 +520,14 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
.find(".typing_indicator_name")
.text(characters[chId].name);
$("#chat").append(typingIndicator);
typingIndicator.show(250, function () {
typingIndicator.show(200, function () {
typingIndicator.get(0).scrollIntoView({ behavior: "smooth" });
});
}
// TODO: This is awful. Refactor this
while (true) {
deactivateSendButtons();
if (isGenerationAborted) {
throw new Error('Group generation aborted');
}
@@ -595,11 +607,10 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
break;
}
}
}
} finally {
// hide and reapply the indicator to the bottom of the list
typingIndicator.hide(250);
typingIndicator.hide(200);
$("#chat").append(typingIndicator);
is_group_generating = false;
@@ -607,6 +618,7 @@ async function generateGroupWrapper(by_auto_mode, type = null, params = {}) {
setSendButtonState(false);
setCharacterId(undefined);
setCharacterName('');
activateSendButtons();
showSwipeButtons();
}
}
@@ -714,7 +726,8 @@ function activateNaturalOrder(members, input, lastMessage, allowSelfResponses, i
}
// pick 1 at random if no one was activated
while (activatedMembers.length === 0) {
let retries = 0;
while (activatedMembers.length === 0 && ++retries <= members.length) {
const randomIndex = Math.floor(Math.random() * members.length);
const character = characters.find((x) => x.avatar === members[randomIndex]);
@@ -959,6 +972,9 @@ function select_group_chats(groupId, skipAnimation) {
$("#rm_group_scenario").show();
} else {
$("#rm_group_submit").show();
if ($("#groupAddMemberListToggle .inline-drawer-content").css('display') !== 'block') {
$("#groupAddMemberListToggle").trigger('click');
}
$("#rm_group_delete").hide();
$("#rm_group_scenario").hide();
}

View File

@@ -162,7 +162,7 @@ export function saveCaretPosition(element) {
end: range.endOffset
};
console.log('Caret saved', position);
console.debug('Caret saved', position);
return position;
}
@@ -174,7 +174,7 @@ export function restoreCaretPosition(element, position) {
return;
}
console.log('Caret restored', position);
console.debug('Caret restored', position);
// Create a new range object
const range = new Range();

View File

@@ -63,29 +63,19 @@ Get in touch with the developers directly:
* Soft prompts selector for KoboldAI
* Prompt generation formatting tweaking
* webp character card interoperability (PNG is still an internal format)
* Extensibility support via [SillyLossy's TAI-extras](https://github.com/Cohee1207/TavernAI-extras) plugins
* Author's Note / Character Bias
* Character emotional expressions
* Auto-Summary of the chat history
* Sending images to chat, and the AI interpreting the content.
* Stable Diffusion image generation (5 chat-related presets plus 'free mode')
* Text-to-speech for AI response messages (via ElevenLabs, Silero, or the OS's System TTS)
## UI Extensions 🚀
## Extensions
| Name | Description | Required <a href="https://github.com/Cohee1207/TavernAI-extras#modules" target="_blank">Extra Modules</a> | Screenshot |
| ---------------- | ---------------------------------| ---------------------------- | ---------- |
| Image Captioning | Send a cute picture to your bot!<br><br>Picture select option will appear beside the "Message send" button. | `caption` | <img src="https://user-images.githubusercontent.com/18619528/224161576-ddfc51cd-995e-44ec-bf2d-d2477d603f0c.png" style="max-width:200px" /> |
| Character Expressions | See your character reacting to your messages!<br><br>**You need to provide your own character images!**<br><br>1. Create a folder in TavernAI called `public/characters/<name>`, where `<name>` is the name of your character.<br>2. For the base emotion classification model, put six PNG files there with the following names: `joy.png`, `anger.png`, `fear.png`, `love.png`, `sadness.png`, `surprise.png`. Other models may provide other options.<br>3. Images only display in desktop mode. | `classify` | <img style="max-width:200px" alt="image" src="https://user-images.githubusercontent.com/18619528/223765089-34968217-6862-47e0-85da-7357370f8de6.png"> |
| Memory | Chatbot long-term memory simulation using automatic message context summarization. | `summarize` | <img style="max-width:200px" alt="image" src="https://user-images.githubusercontent.com/18619528/223766279-88a46481-1fa6-40c5-9724-6cdd6f587233.png"> |
| D&D Dice | A set of 7 classic D&D dice for all your dice rolling needs.<br><br>*I used to roll the dice.<br>Feel the fear in my enemies' eyes* | None | <img style="max-width:200px" alt="image" src="https://user-images.githubusercontent.com/18619528/226199925-a066c6fc-745e-4a2b-9203-1cbffa481b14.png"> |
| Author's Note | Built-in extension that allows you to append notes that will be added to the context and steer the story and character in a specific direction. Because it's sent after the character description, it has a lot of weight. Thanks Ali#2222 for pitching the idea! | None | ![image](https://user-images.githubusercontent.com/128647114/230311637-d809cd9b-af66-4dd1-a310-7a27e847c011.png) |
| Character Backgrounds | Built-in extension to assign unique backgrounds to specific chats or groups. | None | <img style="max-width:200px" alt="image" src="https://user-images.githubusercontent.com/18619528/233494454-bfa7c9c7-4faa-4d97-9c69-628fd96edd92.png"> |
| Stable Diffusion | Use local of cloud-based Stable Diffusion webUI API to generate images. 5 presets included ('you', 'your face', 'me', 'the story', and 'the last message'. Free mode also supported via `/sd (anything_here_)` command in the chat input bar. Most common StableDiffusion generation settings are customizable within the SillyTavern UI. | None | <img style="max-width:200px" alt="image" src="https://files.catbox.moe/ppata8.png"> |
| Text-to-Speech | AI-generated voice will read back character messages on demand, or automatically read new messages they arrive. Supports ElevenLabs, Silero, and your device's TTS service. | None | <img style="max-width:200px" alt="image" src="https://files.catbox.moe/o3wxkk.png"> |
| Chat Translation | Automatically translates incoming and/or outgoing messages into the chosen language. | None | Pending |
| Token Counter | Simple way to calculate the number of tokens in any text with selected tokenizer. | None | Pending |
| Smart Context<br><br>*Infinity Context / Object Permanence* | **What it doesn't do:** Magically increase your context size.<br>**What it does:** Optimizes the arrangement of your message history within the context space for more effective use.<br><br>Imagine two variables:<br>X: How many original chat messages to keep<br>Y: Maximum number of ChromaDB 'memories' to inject<br><br>When the chat reaches the threshold of X messages, additional messages will no longer be included in the context chronologically. Instead, they will be selected from the history based on their similarity to your recent inputs (limited to a maximum of Y), which should provide more relevant information than simply disregarding past messages. Adjust these values according to your average number of in-context entries for optimal performance. | `chromadb` | Pending |
SillyTavern has an extensibility support, with some additional AI modules hosted via [SillyTavern Extras API](https://github.com/SillyTavern/SillyTavern-extras)
* Author's Note / Character Bias
* Character emotional expressions
* Auto-Summary of the chat history
* Sending images to chat, and the AI interpreting the content.
* Stable Diffusion image generation (5 chat-related presets plus 'free mode')
* Text-to-speech for AI response messages (via ElevenLabs, Silero, or the OS's System TTS)
Full list of included extenisons and tutorials how to use them can be found on [Wiki](https://github.com/SillyTavern/SillyTavern/wiki).
## UI/CSS/Quality of Life tweaks by RossAscends