🎨 优化扩展模块,完成ai接入和对话功能

This commit is contained in:
2026-02-12 23:12:28 +08:00
parent 4e611d3a5e
commit 572f3aa15b
779 changed files with 194400 additions and 3136 deletions

View File

@@ -0,0 +1,9 @@
<div class="characterAsset">
<div class="characterAssetName">{{name}}</div>
<img class="characterAssetImage" alt="{{name}}" src="{{url}}" />
<div class="characterAssetDescription" title="{{description}}">{{description}}</div>
<div class="characterAssetButtons flex-container">
<div class="characterAssetDownloadButton right_menu_button fa-fw fa-solid fa-download" title="Download"></div>
<div class="characterAssetCheckMark right_menu_button fa-fw fa-solid fa-check" title="Installed"></div>
</div>
</div>

View File

@@ -0,0 +1,506 @@
/*
TODO:
*/
//const DEBUG_TONY_SAMA_FORK_MODE = true
import { DOMPurify } from '../../../lib.js';
import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
import { deleteExtension, extensionNames, getContext, installExtension, renderExtensionTemplateAsync } from '../../extensions.js';
import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
import { accountStorage } from '../../util/AccountStorage.js';
import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
import { t, translate } from '../../i18n.js';
export { MODULE_NAME };
const MODULE_NAME = 'assets';
const DEBUG_PREFIX = '<Assets module> ';
let previewAudio = null;
let ASSETS_JSON_URL = 'https://raw.githubusercontent.com/SillyTavern/SillyTavern-Content/main/index.json';
// DBG
//if (DEBUG_TONY_SAMA_FORK_MODE)
// ASSETS_JSON_URL = "https://raw.githubusercontent.com/Tony-sama/SillyTavern-Content/main/index.json"
let availableAssets = {};
let currentAssets = {};
//#############################//
// Extension UI and Settings //
//#############################//
function filterAssets() {
const searchValue = String($('#assets_search').val()).toLowerCase().trim();
const typeValue = String($('#assets_type_select').val());
if (typeValue === '') {
$('#assets_menu .assets-list-div').show();
$('#assets_menu .assets-list-div h3').show();
} else {
$('#assets_menu .assets-list-div h3').hide();
$('#assets_menu .assets-list-div').hide();
$(`#assets_menu .assets-list-div[data-type="${typeValue}"]`).show();
}
if (searchValue === '') {
$('#assets_menu .asset-block').show();
} else {
$('#assets_menu .asset-block').hide();
$('#assets_menu .asset-block').filter(function () {
return $(this).text().toLowerCase().includes(searchValue);
}).show();
}
}
const KNOWN_TYPES = {
'extension': t`Extensions`,
'character': t`Characters`,
'ambient': t`Ambient sounds`,
'bgm': t`Background music`,
'blip': t`Blip sounds`,
};
const EMPTY_AUTHOR = {
name: '',
url: '',
};
/**
* Extracts the repository author from a given URL.
* @param {string} url - The URL of the repository.
* @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found.
*/
function getAuthorFromUrl(url) {
const result = structuredClone(EMPTY_AUTHOR);
try {
const parsedUrl = new URL(url);
const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0);
// TODO: Handle non-GitHub URLs if needed
if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) {
result.name = pathSegments[0];
result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`;
}
}
catch (error) {
console.debug(DEBUG_PREFIX, 'Error parsing URL:', error);
}
return result;
}
async function downloadAssetsList(url) {
updateCurrentAssets().then(async function () {
fetch(url, { cache: 'no-cache' })
.then(response => response.json())
.then(async function(json) {
availableAssets = {};
$('#assets_menu').empty();
console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
for (const i of json) {
//console.log(DEBUG_PREFIX,i)
if (availableAssets[i['type']] === undefined)
availableAssets[i['type']] = [];
availableAssets[i['type']].push(i);
}
console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
// First extensions, then everything else
const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
$('#assets_type_select').empty();
$('#assets_search').val('');
$('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
for (const type of assetTypes) {
const text = translate(KNOWN_TYPES[type] || type);
const option = $('<option />', { value: type, text: text });
$('#assets_type_select').append(option);
}
if (assetTypes.includes('extension')) {
$('#assets_type_select').val('extension');
}
$('#assets_type_select').off('change').on('change', filterAssets);
$('#assets_search').off('input').on('input', filterAssets);
for (const assetType of assetTypes) {
let assetTypeMenu = $('<div />', { id: 'assets_audio_ambient_div', class: 'assets-list-div' });
assetTypeMenu.attr('data-type', assetType);
assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide();
if (assetType == 'extension') {
assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
}
for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['name'].localeCompare(b['name']))) {
const i = availableAssets[assetType].indexOf(asset);
const elemId = `assets_install_${assetType}_${i}`;
let element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>');
element.append(label);
//if (DEBUG_TONY_SAMA_FORK_MODE)
// asset["url"] = asset["url"].replace("https://github.com/SillyTavern/","https://github.com/Tony-sama/"); // DBG
console.debug(DEBUG_PREFIX, 'Checking asset', asset['id'], asset['url']);
const assetInstall = async function () {
element.off('click');
label.removeClass('fa-download');
this.classList.add('asset-download-button-loading');
await installAsset(asset['url'], assetType, asset['id']);
label.addClass('fa-check');
this.classList.remove('asset-download-button-loading');
element.on('click', assetDelete);
element.on('mouseenter', function () {
label.removeClass('fa-check');
label.addClass('fa-trash');
label.addClass('redOverlayGlow');
}).on('mouseleave', function () {
label.addClass('fa-check');
label.removeClass('fa-trash');
label.removeClass('redOverlayGlow');
});
};
const assetDelete = async function () {
if (assetType === 'character') {
toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
await executeSlashCommandsWithOptions(`/go ${asset['id']}`);
return;
}
element.off('click');
await deleteAsset(assetType, asset['id']);
label.removeClass('fa-check');
label.removeClass('redOverlayGlow');
label.removeClass('fa-trash');
label.addClass('fa-download');
element.off('mouseenter').off('mouseleave');
element.on('click', assetInstall);
};
if (isAssetInstalled(assetType, asset['id'])) {
console.debug(DEBUG_PREFIX, 'installed, checked');
label.toggleClass('fa-download');
label.toggleClass('fa-check');
element.on('click', assetDelete);
element.on('mouseenter', function () {
label.removeClass('fa-check');
label.addClass('fa-trash');
label.addClass('redOverlayGlow');
}).on('mouseleave', function () {
label.addClass('fa-check');
label.removeClass('fa-trash');
label.removeClass('redOverlayGlow');
});
}
else {
console.debug(DEBUG_PREFIX, 'not installed, unchecked');
element.prop('checked', false);
element.on('click', assetInstall);
}
console.debug(DEBUG_PREFIX, 'Created element for ', asset['id']);
const displayName = DOMPurify.sanitize(asset['name'] || asset['id']);
const description = DOMPurify.sanitize(asset['description'] || '');
const url = isValidUrl(asset['url']) ? asset['url'] : '';
const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
const toolTag = assetType === 'extension' && asset['tool'];
const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
const assetBlock = $('<i></i>')
.append(element)
.append(`<div class="flex-container flexFlowColumn flexNoGap wide100p overflowHidden">
<span class="asset-name flex-container alignitemscenter">
<b>${displayName}</b>
<a class="asset_preview" href="${url}" target="_blank" title="${title}">
<i class="fa-solid fa-sm ${previewIcon}"></i>
</a>` +
(toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' +
t`Tool` + '</span>' : '') +
'<span class="expander"></span>' +
(author.name ? `<a href="${author.url}" target="_blank" class="asset-author-info"><i class="fa-solid fa-at fa-xs"></i><span>${author.name}</span></a>` : '') +
`</span>
<small class="asset-description">
${description}
</small>
</div>`);
assetBlock.find('.tag').on('click', function (e) {
const a = document.createElement('a');
a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/';
a.target = '_blank';
a.click();
});
if (assetType === 'character') {
if (asset.highlight) {
assetBlock.find('.asset-name').append('<i class="fa-solid fa-sm fa-trophy"></i>');
}
assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset['url']}" alt="${displayName}"></div>`);
}
assetBlock.addClass('asset-block');
assetTypeMenu.append(assetBlock);
}
assetTypeMenu.appendTo('#assets_menu');
assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
}
filterAssets();
$('#assets_filters').show();
$('#assets_menu').show();
})
.catch((error) => {
// Info hint if the user maybe... likely accidently was trying to install an extension and we wanna help guide them? uwu :3
const installButton = $('#third_party_extension_button');
flashHighlight(installButton, 10_000);
toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 });
// Error logged after, to appear on top
console.error(error);
toastr.error('Problem with assets URL', DEBUG_PREFIX + 'Cannot get assets list');
$('#assets-connect-button').addClass('fa-plug-circle-exclamation');
$('#assets-connect-button').addClass('redOverlayGlow');
});
});
}
function previewAsset(e) {
const href = $(this).attr('href');
const audioExtensions = ['.mp3', '.ogg', '.wav'];
if (audioExtensions.some(ext => href.endsWith(ext))) {
e.preventDefault();
if (previewAudio) {
previewAudio.pause();
if (previewAudio.src === href) {
previewAudio = null;
return;
}
}
previewAudio = new Audio(href);
previewAudio.play();
return;
}
}
function isAssetInstalled(assetType, filename) {
let assetList = currentAssets[assetType];
if (assetType == 'extension') {
const thirdPartyMarker = 'third-party/';
assetList = extensionNames.filter(x => x.startsWith(thirdPartyMarker)).map(x => x.replace(thirdPartyMarker, ''));
}
if (assetType == 'character') {
assetList = getContext().characters.map(x => x.avatar);
}
for (const i of assetList) {
//console.debug(DEBUG_PREFIX,i,filename)
if (i.includes(filename))
return true;
}
return false;
}
async function installAsset(url, assetType, filename) {
console.debug(DEBUG_PREFIX, 'Downloading ', url);
const category = assetType;
try {
if (category === 'extension') {
console.debug(DEBUG_PREFIX, 'Installing extension ', url);
await installExtension(url, false);
console.debug(DEBUG_PREFIX, 'Extension installed.');
return;
}
const body = { url, category, filename };
const result = await fetch('/api/assets/download', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(body),
cache: 'no-cache',
});
if (result.ok) {
console.debug(DEBUG_PREFIX, 'Download success.');
if (category === 'character') {
console.debug(DEBUG_PREFIX, 'Importing character ', filename);
const blob = await result.blob();
const file = new File([blob], filename, { type: blob.type });
await processDroppedFiles([file]);
console.debug(DEBUG_PREFIX, 'Character downloaded.');
}
}
}
catch (err) {
console.log(err);
return [];
}
}
async function deleteAsset(assetType, filename) {
console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename);
const category = assetType;
try {
if (category === 'extension') {
console.debug(DEBUG_PREFIX, 'Deleting extension ', filename);
await deleteExtension(filename);
console.debug(DEBUG_PREFIX, 'Extension deleted.');
}
const body = { category, filename };
const result = await fetch('/api/assets/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(body),
cache: 'no-cache',
});
if (result.ok) {
console.debug(DEBUG_PREFIX, 'Deletion success.');
}
}
catch (err) {
console.log(err);
return [];
}
}
async function openCharacterBrowser(forceDefault) {
const url = forceDefault ? ASSETS_JSON_URL : String($('#assets-json-url-field').val());
const fetchResult = await fetch(url, { cache: 'no-cache' });
const json = await fetchResult.json();
const characters = json.filter(x => x.type === 'character');
if (!characters.length) {
toastr.error('No characters found in the assets list', 'Character browser');
return;
}
const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'market', {}));
for (const character of characters.sort((a, b) => a.name.localeCompare(b.name))) {
const listElement = template.find(character.highlight ? '.contestWinnersList' : '.featuredCharactersList');
const characterElement = $(await renderExtensionTemplateAsync(MODULE_NAME, 'character', character));
const downloadButton = characterElement.find('.characterAssetDownloadButton');
const checkMark = characterElement.find('.characterAssetCheckMark');
const isInstalled = isAssetInstalled('character', character.id);
downloadButton.toggle(!isInstalled).on('click', async () => {
downloadButton.toggleClass('fa-download fa-spinner fa-spin');
await installAsset(character.url, 'character', character.id);
downloadButton.hide();
checkMark.show();
});
checkMark.toggle(isInstalled);
listElement.append(characterElement);
}
callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, allowVerticalScrolling: true, allowHorizontalScrolling: false });
}
//#############################//
// API Calls //
//#############################//
async function updateCurrentAssets() {
console.debug(DEBUG_PREFIX, 'Checking installed assets...');
try {
const result = await fetch('/api/assets/get', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
});
currentAssets = result.ok ? (await result.json()) : {};
}
catch (err) {
console.log(err);
}
console.debug(DEBUG_PREFIX, 'Current assets found:', currentAssets);
}
//#############################//
// Extension load //
//#############################//
// This function is called when the extension is loaded
jQuery(async () => {
// This is an example of loading HTML from a file
const windowTemplate = await renderExtensionTemplateAsync(MODULE_NAME, 'window', {});
const windowHtml = $(windowTemplate);
const assetsJsonUrl = windowHtml.find('#assets-json-url-field');
assetsJsonUrl.val(ASSETS_JSON_URL);
const charactersButton = windowHtml.find('#assets-characters-button');
charactersButton.on('click', async function () {
openCharacterBrowser(false);
});
const installHintButton = windowHtml.find('.assets-install-hint-link');
installHintButton.on('click', async function () {
const installButton = $('#third_party_extension_button');
flashHighlight(installButton, 5000);
toastr.info(t`Click the flashing button to install extensions.`, t`How to install extensions?`);
});
const connectButton = windowHtml.find('#assets-connect-button');
connectButton.on('click', async function () {
const url = DOMPurify.sanitize(String(assetsJsonUrl.val()));
const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '<span>' + t`Are you sure you want to connect to the following url?` + `</span><var>${url}</var>`, {
customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
onClose: popup => {
if (popup.result) {
const rememberValue = popup.inputResults.get('assets-remember');
accountStorage.setItem(rememberKey, String(rememberValue));
}
},
});
if (confirmation) {
try {
console.debug(DEBUG_PREFIX, 'Confimation, loading assets...');
downloadAssetsList(url);
connectButton.removeClass('fa-plug-circle-exclamation');
connectButton.removeClass('redOverlayGlow');
connectButton.addClass('fa-plug-circle-check');
} catch (error) {
console.error('Error:', error);
toastr.error(`Cannot get assets list from ${url}`);
connectButton.removeClass('fa-plug-circle-check');
connectButton.addClass('fa-plug-circle-exclamation');
connectButton.removeClass('redOverlayGlow');
}
}
else {
console.debug(DEBUG_PREFIX, 'Connection refused by user');
}
});
windowHtml.find('#assets_filters').hide();
$('#assets_container').append(windowHtml);
eventSource.on(event_types.OPEN_CHARACTER_LIBRARY, async (forceDefault) => {
openCharacterBrowser(forceDefault);
});
});

View File

@@ -0,0 +1,4 @@
<div class="assets-list-git">
<span data-i18n="extension_install_1">To download extensions from this page, you need to have </span><a href="https://git-scm.com/downloads" target="_blank">Git</a><span data-i18n="extension_install_2"> installed.</span><br>
<span data-i18n="extension_install_3">Click the </span><i class="fa-solid fa-sm fa-arrow-up-right-from-square"></i><span data-i18n="extension_install_4"> icon to visit the Extension's repo for tips on how to use it.</span>
</div>

View File

@@ -0,0 +1,11 @@
{
"display_name": "Assets",
"loading_order": 15,
"requires": [],
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "Keij#6799",
"version": "0.1.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}

View File

@@ -0,0 +1,19 @@
<div class="flex-container flexFlowColumn padding5">
<div class="contestWinners flex-container flexFlowColumn">
<h3 class="flex-container alignItemsBaseline justifyCenter" data-i18n="[title]These characters are the winners of character design contests and have outstandable quality." title="These characters are the winners of character design contests and have outstandable quality.">
<span data-i18n="Contest Winners">Contest Winners</span>
<i class="fa-solid fa-star"></i>
</h3>
<div class="contestWinnersList characterAssetList">
</div>
</div>
<hr>
<div class="featuredCharacters flex-container flexFlowColumn">
<h3 class="flex-container alignItemsBaseline justifyCenter" data-i18n="[title]These characters are the finalists of character design contests and have remarkable quality." title="These characters are the finalists of character design contests and have remarkable quality.">
<span data-i18n="Featured Characters">Featured Characters</span>
<i class="fa-solid fa-thumbs-up"></i>
</h3>
<div class="featuredCharactersList characterAssetList">
</div>
</div>
</div>

View File

@@ -0,0 +1,200 @@
#assets-json-url-field {
width: 85%;
}
#assets-connect-button {
width: 15%;
margin-left: 5px;
}
.assets-url-block {
display: flex;
flex-direction: column;
}
.assets-install-hint-link {
cursor: help;
}
.assets-connect-div {
display: flex;
flex-direction: row;
}
.assets-list-git {
font-size: calc(var(--mainFontSize) * 0.8);
opacity: 0.8;
margin-bottom: 0.25em;
}
.assets-list-div h3 {
text-transform: capitalize;
}
.assets-list-div i a {
color: inherit;
}
.assets-list-div>i {
display: flex;
flex-direction: row;
align-items: center;
justify-content: left;
padding: 10px 5px;
font-style: normal;
gap: 5px;
border-bottom: 1px solid var(--SmartThemeBorderColor);
}
.assets-list-div i span:first-of-type {
font-weight: bold;
}
.asset-download-button {
position: relative;
border: none;
outline: none;
border-radius: 2px;
cursor: pointer;
filter: none !important;
}
.asset-download-button:active {
background: #007a63;
}
.asset-download-button-text {
font: bold 20px "Quicksand", san-serif;
color: #ffffff;
transition: all var(--animation-duration-2x);
}
.asset-download-button-loading .asset-download-button-text {
visibility: hidden;
opacity: 0;
}
.asset-download-button-loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
border: 4px solid transparent;
border-top-color: #ffffff;
border-radius: 50%;
animation: asset-download-button-loading-spinner 1s ease infinite;
}
.asset-name .avatar {
--imgSize: 30px !important;
flex: unset;
width: var(--imgSize);
height: var(--imgSize);
}
.asset-name .avatar img {
width: var(--imgSize);
height: var(--imgSize);
border-radius: 50%;
object-fit: cover;
object-position: center center;
}
@keyframes asset-download-button-loading-spinner {
from {
transform: rotate(0turn);
}
to {
transform: rotate(1turn);
}
}
.characterAssetList {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
}
.characterAsset {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
gap: 10px;
border: 1px solid var(--SmartThemeBorderColor);
background-color: var(--black30a);
border-radius: 10px;
width: 17%;
min-width: 150px;
margin: 5px;
overflow: hidden;
}
.characterAssetName {
font-size: 1.2em;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.characterAssetImage {
max-height: 140px;
object-fit: scale-down;
border-radius: 5px;
}
.characterAssetDescription {
font-size: 0.75em;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
flex: 1;
}
.characterAssetButtons {
display: flex;
flex-direction: row;
gap: 5px;
align-items: center;
}
.asset-name .tag {
gap: 5px;
align-items: baseline;
font-size: calc(var(--mainFontSize)* 0.8);
cursor: pointer;
opacity: 0.9;
margin-left: 2px;
}
.asset-name .asset-author-info {
display: flex;
align-items: baseline;
gap: 2px;
opacity: 0.7;
font-size: 0.85em;
overflow: hidden;
}
.asset-name .asset-author-info>span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.asset-name .asset-author-info:hover {
opacity: 1;
transition: opacity var(--animation-duration) ease-in-out;
}
.asset-name>b {
font-weight: 600;
}

View File

@@ -0,0 +1,46 @@
<div id="assets_ui">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b data-i18n="Download Extensions & Assets">Download Extensions & Assets</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<small>
<span data-i18n="Load a custom asset list or select">
Load a custom asset list or select
</span>
<a class="assets-install-hint-link" data-i18n="Install extension">Install&nbsp;Extension</a>
<span data-i18n="to install 3rd party extensions.">
to install 3rd party extensions.
</span>
</small>
<div class="assets-url-block m-b-1 m-t-1">
<label for="assets-json-url-field" data-i18n="Assets URL">Assets URL</label>
<small data-i18n="[title]load_asset_list_desc" title="Load a list of extensions & assets based on an asset list file.
The default Asset URL in this field points to the list of offical first party extensions and assets.
If you have a custom asset list, you can insert it here.
To install a single 3rd party extension, use the &quot;Install Extensions&quot; button on the top right.">
<span data-i18n="Load an asset list">Load an asset list</span>
<div class="fa-solid fa-circle-info opacity50p"></div>
</small>
<div class="assets-connect-div">
<input id="assets-json-url-field" class="text_pole widthUnset flex1">
<i id="assets-connect-button" class="menu_button fa-solid fa-plug-circle-exclamation fa-xl redOverlayGlow" title="Load Asset List" data-i18n="[title]Load Asset List"></i>
</div>
</div>
<div id="assets_filters" class="flex-container">
<select id="assets_type_select" class="text_pole flex1">
</select>
<input id="assets_search" class="text_pole flex1" data-i18n="[placeholder]Search" placeholder="Search" type="search">
<div id="assets-characters-button" class="menu_button menu_button_icon">
<i class="fa-solid fa-image-portrait"></i>
<span data-i18n="Characters">Characters</span>
</div>
</div>
<div id="assets_menu">
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,4 @@
<div id="attachFile" class="list-group-item flex-container flexGap5" data-i18n="[title]Attach a file or image to a current chat." title="Attach a file or image to a current chat.">
<div class="fa-fw fa-solid fa-paperclip extensionsMenuExtensionButton"></div>
<span data-i18n="Attach a File">Attach a File</span>
</div>

View File

@@ -0,0 +1,51 @@
<div>
<div class="flex-container flexFlowColumn">
<label for="fandomScrapeInput" data-i18n="Enter a URL or the ID of a Fandom wiki page to scrape:">
Enter a URL or the ID of a Fandom wiki page to scrape:
</label>
<small>
<span data-i18n="Examples:">Examples:</span>
<code>https://harrypotter.fandom.com/</code>
<span data-i18n="or">or</span>
<code>harrypotter</code>
</small>
<input type="text" id="fandomScrapeInput" name="fandomScrapeInput" class="text_pole" placeholder="">
</div>
<div class="flex-container flexFlowColumn">
<label for="fandomScrapeFilter">
Optional regex to pick the content by its title:
</label>
<small>
<span data-i18n="Example:">Example:</span>
<code>/(Azkaban|Weasley)/gi</code>
</small>
<input type="text" id="fandomScrapeFilter" name="fandomScrapeFilter" class="text_pole" placeholder="">
</div>
<div class="flex-container flexFlowColumn">
<label>
Output format:
</label>
<label class="checkbox_label justifyLeft" for="fandomScrapeOutputSingle">
<input id="fandomScrapeOutputSingle" type="radio" name="fandomScrapeOutput" value="single" checked>
<div class="flex-container flexFlowColumn flexNoGap">
<span data-i18n="Single file">
Single file
</span>
<small data-i18n="All articles will be concatenated into a single file.">
All articles will be concatenated into a single file.
</small>
</div>
</label>
<label class="checkbox_label justifyLeft" for="fandomScrapeOutputMulti">
<input id="fandomScrapeOutputMulti" type="radio" name="fandomScrapeOutput" value="multi">
<div class="flex-container flexFlowColumn flexNoGap">
<span data-i18n="File per article">
File per article
</span>
<small data-i18n="Each article will be saved as a separate file.">
Not recommended. Each article will be saved as a separate file.
</small>
</div>
</label>
</div>
</div>

View File

@@ -0,0 +1,8 @@
<div class="flex-container justifyCenter alignItemsBaseline">
<span>Save <span class="droppedFilesCount">{{count}}</span> file(s) to...</span>
<select class="droppedFilesTarget">
{{#each targets}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
</div>

View File

@@ -0,0 +1,410 @@
import { event_types, eventSource, saveSettingsDebounced } from '../../../script.js';
import { deleteAttachment, getDataBankAttachments, getDataBankAttachmentsForSource, getFileAttachment, uploadFileAttachmentToServer } from '../../chats.js';
import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
import { SlashCommandExecutor } from '../../slash-commands/SlashCommandExecutor.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
/**
* List of attachment sources
* @type {string[]}
*/
const TYPES = ['global', 'character', 'chat'];
const FIELDS = ['name', 'url'];
/**
* Get attachments from the data bank. Includes disabled attachments.
* @param {string} [source] Source for the attachments
* @returns {import('../../chats').FileAttachment[]} List of attachments
*/
function getAttachments(source) {
if (!source || !TYPES.includes(source)) {
return getDataBankAttachments(true);
}
return getDataBankAttachmentsForSource(source, true);
}
/**
* Get attachment by a single name or URL.
* @param {import('../../chats').FileAttachment[]} attachments List of attachments
* @param {string} value Name or URL of the attachment
* @returns {import('../../chats').FileAttachment} Attachment
*/
function getAttachmentByField(attachments, value) {
const match = (a) => String(a).trim().toLowerCase() === String(value).trim().toLowerCase();
const fullMatchByURL = attachments.find(it => match(it.url));
const fullMatchByName = attachments.find(it => match(it.name));
return fullMatchByURL || fullMatchByName;
}
/**
* Get attachment by multiple fields.
* @param {import('../../chats').FileAttachment[]} attachments List of attachments
* @param {string[]} values Name and URL of the attachment to search for
* @returns
*/
function getAttachmentByFields(attachments, values) {
for (const value of values) {
const attachment = getAttachmentByField(attachments, value);
if (attachment) {
return attachment;
}
}
return null;
}
/**
* Callback for listing attachments in the data bank.
* @param {object} args Named arguments
* @returns {string} JSON string of the list of attachments
*/
function listDataBankAttachments(args) {
const attachments = getAttachments(args?.source);
const field = args?.field;
return JSON.stringify(attachments.map(a => FIELDS.includes(field) ? a[field] : a.url));
}
/**
* Callback for getting text from an attachment in the data bank.
* @param {object} args Named arguments
* @param {string} value Name or URL of the attachment
* @returns {Promise<string>} Content of the attachment
*/
async function getDataBankText(args, value) {
if (!value) {
toastr.warning('No attachment name or URL provided.');
return;
}
const attachments = getAttachments(args?.source);
const attachment = getAttachmentByField(attachments, value);
if (!attachment) {
toastr.warning('Attachment not found.');
return;
}
const content = await getFileAttachment(attachment.url);
return content;
}
/**
* Callback for adding an attachment to the data bank.
* @param {object} args Named arguments
* @param {string} value Content of the attachment
* @returns {Promise<string>} URL of the attachment
*/
async function uploadDataBankAttachment(args, value) {
const source = args?.source && TYPES.includes(args.source) ? args.source : 'chat';
const name = args?.name || new Date().toLocaleString();
const file = new File([value], name, { type: 'text/plain' });
const url = await uploadFileAttachmentToServer(file, source);
return url;
}
/**
* Callback for updating an attachment in the data bank.
* @param {object} args Named arguments
* @param {string} value Content of the attachment
* @returns {Promise<string>} URL of the attachment
*/
async function updateDataBankAttachment(args, value) {
const source = args?.source && TYPES.includes(args.source) ? args.source : 'chat';
const attachments = getAttachments(source);
const attachment = getAttachmentByFields(attachments, [args?.url, args?.name]);
if (!attachment) {
toastr.warning('Attachment not found.');
return '';
}
await deleteAttachment(attachment, source, () => { }, false);
const file = new File([value], attachment.name, { type: 'text/plain' });
const url = await uploadFileAttachmentToServer(file, source);
return url;
}
/**
* Callback for deleting an attachment from the data bank.
* @param {object} args Named arguments
* @param {string} value Name or URL of the attachment
* @returns {Promise<string>} Empty string
*/
async function deleteDataBankAttachment(args, value) {
const source = args?.source && TYPES.includes(args.source) ? args.source : 'chat';
const attachments = getAttachments(source);
const attachment = getAttachmentByField(attachments, value);
if (!attachment) {
toastr.warning('Attachment not found.');
return '';
}
await deleteAttachment(attachment, source, () => { }, false);
return '';
}
/**
* Callback for disabling an attachment in the data bank.
* @param {object} args Named arguments
* @param {string} value Name or URL of the attachment
* @returns {Promise<string>} Empty string
*/
async function disableDataBankAttachment(args, value) {
const attachments = getAttachments(args?.source);
const attachment = getAttachmentByField(attachments, value);
if (!attachment) {
toastr.warning('Attachment not found.');
return '';
}
if (extension_settings.disabled_attachments.includes(attachment.url)) {
return '';
}
extension_settings.disabled_attachments.push(attachment.url);
return '';
}
/**
* Callback for enabling an attachment in the data bank.
* @param {object} args Named arguments
* @param {string} value Name or URL of the attachment
* @returns {Promise<string>} Empty string
*/
async function enableDataBankAttachment(args, value) {
const attachments = getAttachments(args?.source);
const attachment = getAttachmentByField(attachments, value);
if (!attachment) {
toastr.warning('Attachment not found.');
return '';
}
const index = extension_settings.disabled_attachments.indexOf(attachment.url);
if (index === -1) {
return '';
}
extension_settings.disabled_attachments.splice(index, 1);
return '';
}
function cleanUpAttachments() {
let shouldSaveSettings = false;
if (extension_settings.character_attachments) {
Object.values(extension_settings.character_attachments).flat().filter(a => a.text).forEach(a => {
shouldSaveSettings = true;
delete a.text;
});
}
if (Array.isArray(extension_settings.attachments)) {
extension_settings.attachments.filter(a => a.text).forEach(a => {
shouldSaveSettings = true;
delete a.text;
});
}
if (shouldSaveSettings) {
saveSettingsDebounced();
}
}
/**
* Clean up character attachments when a character is deleted.
* @param {{character: Character}} data Event data
*/
function cleanUpCharacterAttachments(data) {
const avatar = data?.character?.avatar;
if (!avatar) return;
if (Array.isArray(extension_settings?.character_attachments?.[avatar])) {
delete extension_settings.character_attachments[avatar];
saveSettingsDebounced();
}
}
/**
* Handle character rename event to update character attachments.
* @param {string} oldAvatar Old avatar name
* @param {string} newAvatar New avatar name
*/
function handleCharacterRename(oldAvatar, newAvatar) {
if (!oldAvatar || !newAvatar) return;
if (Array.isArray(extension_settings?.character_attachments?.[oldAvatar])) {
extension_settings.character_attachments[newAvatar] = extension_settings.character_attachments[oldAvatar];
delete extension_settings.character_attachments[oldAvatar];
saveSettingsDebounced();
}
}
jQuery(async () => {
eventSource.on(event_types.APP_READY, cleanUpAttachments);
eventSource.on(event_types.CHARACTER_DELETED, cleanUpCharacterAttachments);
eventSource.on(event_types.CHARACTER_RENAMED, handleCharacterRename);
const manageButton = await renderExtensionTemplateAsync('attachments', 'manage-button', {});
const attachButton = await renderExtensionTemplateAsync('attachments', 'attach-button', {});
$('#data_bank_wand_container').append(manageButton);
$('#attach_file_wand_container').append(attachButton);
/** A collection of local enum providers for this context of data bank */
const localEnumProviders = {
/**
* All attachments in the data bank based on the source argument. If not provided, defaults to 'chat'.
* @param {'name' | 'url'} returnField - Whether the enum should return the 'name' field or the 'url'
* @param {'chat' | 'character' | 'global' | ''} fallbackSource - The source to use if the source argument is not provided. Empty string to use all sources.
* */
attachments: (returnField = 'name', fallbackSource = 'chat') => (/** @type {SlashCommandExecutor} */ executor) => {
const source = executor.namedArgumentList.find(it => it.name == 'source')?.value ?? fallbackSource;
if (source instanceof SlashCommandClosure) throw new Error('Argument \'source\' does not support closures');
const attachments = getAttachments(source);
return attachments.map(attachment => new SlashCommandEnumValue(
returnField === 'name' ? attachment.name : attachment.url,
`${enumIcons.getStateIcon(!extension_settings.disabled_attachments.includes(attachment.url))} [${source}] ${returnField === 'url' ? attachment.name : attachment.url}`,
enumTypes.enum, enumIcons.file));
},
};
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db',
callback: () => {
document.getElementById('manageAttachments')?.click();
return '';
},
aliases: ['databank', 'data-bank'],
helpString: 'Open the data bank',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-list',
callback: listDataBankAttachments,
aliases: ['databank-list', 'data-bank-list'],
helpString: 'List attachments in the Data Bank as a JSON-serialized array. Optionally, provide the source of the attachments and the field to list by.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachments.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
new SlashCommandNamedArgument('field', 'The field to list by.', ARGUMENT_TYPE.STRING, false, false, 'url', FIELDS),
],
returns: ARGUMENT_TYPE.LIST,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-get',
callback: getDataBankText,
aliases: ['databank-get', 'data-bank-get'],
helpString: 'Get attachment text from the Data Bank. Either provide the name or URL of the attachment. Optionally, provide the source of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name or URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
acceptsMultiple: false,
enumProvider: localEnumProviders.attachments('name', ''),
}),
],
returns: ARGUMENT_TYPE.STRING,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-add',
callback: uploadDataBankAttachment,
aliases: ['databank-add', 'data-bank-add'],
helpString: 'Add an attachment to the Data Bank. If name is not provided, it will be generated automatically. Returns the URL of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source for the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
new SlashCommandNamedArgument('name', 'The name of the attachment.', ARGUMENT_TYPE.STRING, false, false),
],
unnamedArgumentList: [
new SlashCommandArgument('The content of the file attachment.', ARGUMENT_TYPE.STRING, true, false),
],
returns: ARGUMENT_TYPE.STRING,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-update',
callback: updateDataBankAttachment,
aliases: ['databank-update', 'data-bank-update'],
helpString: 'Update an attachment in the Data Bank, preserving its name. Returns a new URL of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source for the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
SlashCommandNamedArgument.fromProps({
name: 'name',
description: 'The name of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: localEnumProviders.attachments('name'),
}),
SlashCommandNamedArgument.fromProps({
name: 'url',
description: 'The URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: localEnumProviders.attachments('url'),
}),
],
unnamedArgumentList: [
new SlashCommandArgument('The content of the file attachment.', ARGUMENT_TYPE.STRING, true, false),
],
returns: ARGUMENT_TYPE.STRING,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-disable',
callback: disableDataBankAttachment,
aliases: ['databank-disable', 'data-bank-disable'],
helpString: 'Disable an attachment in the Data Bank by its name or URL. Optionally, provide the source of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name or URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.attachments('name', ''),
}),
],
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-enable',
callback: enableDataBankAttachment,
aliases: ['databank-enable', 'data-bank-enable'],
helpString: 'Enable an attachment in the Data Bank by its name or URL. Optionally, provide the source of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name or URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.attachments('name', ''),
}),
],
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-delete',
callback: deleteDataBankAttachment,
aliases: ['databank-delete', 'data-bank-delete'],
helpString: 'Delete an attachment from the Data Bank.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name or URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.attachments(),
}),
],
}));
});

View File

@@ -0,0 +1,6 @@
<div id="manageAttachments" class="list-group-item flex-container flexGap5" title="View global, character, or data files.">
<div class="fa-fw fa-solid fa-book-open-reader extensionsMenuExtensionButton"></div>
<span data-i18n="Open Data Bank">Open Data Bank</span>
</div>

View File

@@ -0,0 +1,157 @@
<div class="wide100p padding5 dataBankAttachments">
<h2 class="marginBot5">
<span data-i18n="Data Bank">
Data Bank
</span>
</h2>
<div data-i18n="These files will be available for extensions that support attachments (e.g. Vector Storage).">
These files will be available for extensions that support attachments (e.g. Vector Storage).
</div>
<div class="marginTopBot5">
<span data-i18n="Supported file types: Plain Text, PDF, Markdown, HTML, EPUB." >
Supported file types: Plain Text, PDF, Markdown, HTML, EPUB.
</span>
<span data-i18n="Drag and drop files here to upload.">
Drag and drop files here to upload.
</span>
</div>
<div class="flex-container marginTopBot5">
<input type="search" id="attachmentSearch" class="attachmentSearch text_pole margin0 flex1" placeholder="Search...">
<select id="attachmentSort" class="attachmentSort text_pole margin0 flex1 textarea_compact">
<option data-sort-field="created" data-sort-order="desc" data-i18n="Date (Newest First)">
Date (Newest First)
</option>
<option data-sort-field="created" data-sort-order="asc" data-i18n="Date (Oldest First)">
Date (Oldest First)
</option>
<option data-sort-field="name" data-sort-order="asc" data-i18n="Name (A-Z)">
Name (A-Z)
</option>
<option data-sort-field="name" data-sort-order="desc" data-i18n="Name (Z-A)">
Name (Z-A)
</option>
<option data-sort-field="size" data-sort-order="asc" data-i18n="Size (Smallest First)">
Size (Smallest First)
</option>
<option data-sort-field="size" data-sort-order="desc" data-i18n="Size (Largest First)">
Size (Largest First)
</option>
</select>
<label class="margin0 menu_button menu_button_icon attachmentsBulkEditButton">
<i class="fa-solid fa-edit"></i>
<span data-i18n="Bulk Edit">Bulk Edit</span>
<input type="checkbox" class="displayNone attachmentsBulkEditCheckbox" hidden>
</label>
</div>
<div class="attachmentBulkActionsContainer flex-container marginTopBot5 alignItemsBaseline">
<div class="flex-container">
<div class="menu_button menu_button_icon bulkActionSelectAll" title="Select all *visible* attachments">
<i class="fa-solid fa-check-square"></i>
<span data-i18n="Select All">Select All</span>
</div>
<div class="menu_button menu_button_icon bulkActionSelectNone" title="Deselect all *visible* attachments">
<i class="fa-solid fa-square"></i>
<span data-i18n="Select None">Select None</span>
</div>
<div class="menu_button menu_button_icon bulkActionDisable" title="Disable selected attachments">
<i class="fa-solid fa-comment-slash"></i>
<span data-i18n="Disable">Disable</span>
</div>
<div class="menu_button menu_button_icon bulkActionEnable" title="Enable selected attachments">
<i class="fa-solid fa-comment"></i>
<span data-i18n="Enable">Enable</span>
</div>
<div class="menu_button menu_button_icon bulkActionDelete" title="Delete selected attachments">
<i class="fa-solid fa-trash"></i>
<span data-i18n="Delete">Delete</span>
</div>
</div>
</div>
<div class="justifyLeft globalAttachmentsBlock marginBot10">
<h3 class="globalAttachmentsTitle margin0 title_restorable">
<span data-i18n="Global Attachments">
Global Attachments
</span>
<div class="openActionModalButton menu_button menu_button_icon">
<i class="fa-solid fa-plus"></i>
<span data-i18n="Add">Add</span>
</div>
</h3>
<small data-i18n="These files are available for all characters in all chats.">
These files are available for all characters in all chats.
</small>
<div class="globalAttachmentsList attachmentsList"></div>
<hr>
</div>
<div class="justifyLeft characterAttachmentsBlock marginBot10">
<h3 class="characterAttachmentsTitle margin0 title_restorable">
<span data-i18n="Character Attachments">
Character Attachments
</span>
<div class="openActionModalButton menu_button menu_button_icon">
<i class="fa-solid fa-plus"></i>
<span data-i18n="Add">Add</span>
</div>
</h3>
<div class="flex-container flexFlowColumn">
<strong><small class="characterAttachmentsName"></small></strong>
<small>
<span data-i18n="These files are available for the current character in all chats they are in.">
These files are available for the current character in all chats they are in.
</span>
<span>
<span data-i18n="Saved locally. Not exported.">
Saved locally. Not exported.
</span>
</span>
</small>
</div>
<div class="characterAttachmentsList attachmentsList"></div>
<hr>
</div>
<div class="justifyLeft chatAttachmentsBlock marginBot10">
<h3 class="chatAttachmentsTitle margin0 title_restorable">
<span data-i18n="Chat Attachments">
Chat Attachments
</span>
<div class="openActionModalButton menu_button menu_button_icon">
<i class="fa-solid fa-plus"></i>
<span data-i18n="Add">Add</span>
</div>
</h3>
<div class="flex-container flexFlowColumn">
<strong><small class="chatAttachmentsName"></small></strong>
<small data-i18n="These files are available for all characters in the current chat.">
These files are available for all characters in the current chat.
</small>
</div>
<div class="chatAttachmentsList attachmentsList"></div>
</div>
<div class="attachmentListItemTemplate template_element">
<div class="attachmentListItem flex-container alignItemsCenter flexGap10">
<div class="attachmentListItemCheckboxContainer"><input type="checkbox" class="attachmentListItemCheckbox"></div>
<div class="attachmentFileIcon fa-solid fa-file-alt"></div>
<div class="attachmentListItemName flex1"></div>
<small class="attachmentListItemCreated"></small>
<small class="attachmentListItemSize"></small>
<div class="viewAttachmentButton right_menu_button fa-fw fa-solid fa-magnifying-glass" title="View attachment content"></div>
<div class="disableAttachmentButton right_menu_button fa-fw fa-solid fa-comment" title="Disable attachment"></div>
<div class="enableAttachmentButton right_menu_button fa-fw fa-solid fa-comment-slash" title="Enable attachment"></div>
<div class="moveAttachmentButton right_menu_button fa-fw fa-solid fa-arrows-alt" title="Move attachment"></div>
<div class="editAttachmentButton right_menu_button fa-fw fa-solid fa-pencil" title="Edit attachment"></div>
<div class="downloadAttachmentButton right_menu_button fa-fw fa-solid fa-download" title="Download attachment"></div>
<div class="deleteAttachmentButton right_menu_button fa-fw fa-solid fa-trash" title="Delete attachment"></div>
</div>
</div>
<div class="actionButtonTemplate">
<div class="actionButton list-group-item flex-container flexGap5" style="align-items: center;" title="">
<i class="actionButtonIcon"></i>
<img class="actionButtonImg"/>
<span class="actionButtonText"></span>
</div>
</div>
<div class="actionButtonsModal popper-modal options-content list-group"></div>
</div>

View File

@@ -0,0 +1,11 @@
{
"display_name": "Data Bank (Chat Attachments)",
"loading_order": 3,
"requires": [],
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "Cohee1207",
"version": "1.0.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}

View File

@@ -0,0 +1,54 @@
<div>
<div class="flex-container flexFlowColumn">
<label for="scrapeInput" data-i18n="Enter a base URL of the MediaWiki to scrape.">
Enter a <strong>base URL</strong> of the MediaWiki to scrape.
</label>
<i data-i18n="Don't include the page name!">
Don't include the page name!
</i>
<small>
<span data-i18n="Examples:">Examples:</span>
<code>https://streetcat.wiki/index.php</code>
<span data-i18n="or">or</span>
<code>https://tcrf.net</code>
</small>
<input type="text" id="scrapeInput" name="scrapeInput" class="text_pole" placeholder="">
</div>
<div class="flex-container flexFlowColumn">
<label for="scrapeFilter">
Optional regex to pick the content by its title:
</label>
<small>
<span data-i18n="Example:">Example:</span>
<code>/Mr. (Fresh|Snack)/gi</code>
</small>
<input type="text" id="scrapeFilter" name="scrapeFilter" class="text_pole" placeholder="">
</div>
<div class="flex-container flexFlowColumn">
<label>
Output format:
</label>
<label class="checkbox_label justifyLeft" for="scrapeOutputSingle">
<input id="scrapeOutputSingle" type="radio" name="scrapeOutput" value="single" checked>
<div class="flex-container flexFlowColumn flexNoGap">
<span data-i18n="Single file">
Single file
</span>
<small data-i18n="All articles will be concatenated into a single file.">
All articles will be concatenated into a single file.
</small>
</div>
</label>
<label class="checkbox_label justifyLeft" for="scrapeOutputMulti">
<input id="scrapeOutputMulti" type="radio" name="scrapeOutput" value="multi">
<div class="flex-container flexFlowColumn flexNoGap">
<span data-i18n="File per article">
File per article
</span>
<small data-i18n="Each article will be saved as a separate file.">
Not recommended. Each article will be saved as a separate file.
</small>
</div>
</label>
</div>
</div>

View File

@@ -0,0 +1,8 @@
<div class="flex-container justifyCenter alignItemsBaseline">
<span>Move <strong class="moveAttachmentName">{{name}}</strong> to...</span>
<select class="moveAttachmentTarget">
{{#each targets}}
<option value="{{this}}">{{this}}</option>
{{/each}}
</select>
</div>

View File

@@ -0,0 +1,10 @@
<div class="flex-container flexFlowColumn height100p">
<label for="notepadFileName">
File Name
</label>
<input type="text" class="text_pole" id="notepadFileName" name="notepadFileName" value="" />
<label>
File Content
</label>
<textarea id="notepadFileContent" name="notepadFileContent" class="text_pole textarea_compact monospace flex1" placeholder="Enter your notes here."></textarea>
</div>

View File

@@ -0,0 +1,63 @@
.attachmentsList:empty {
width: 100%;
height: 100%;
}
.attachmentsList:empty::before {
display: flex;
align-items: center;
justify-content: center;
content: "No data";
font-weight: bolder;
width: 100%;
height: 100%;
opacity: 0.8;
min-height: 3rem;
}
.attachmentListItem {
padding: 10px;
}
.attachmentListItem.disabled .attachmentListItemName {
text-decoration: line-through;
opacity: 0.75;
}
.attachmentListItem.disabled .attachmentFileIcon {
opacity: 0.75;
cursor: not-allowed;
}
.attachmentListItemSize {
min-width: 4em;
text-align: right;
}
.attachmentListItemCreated {
text-align: right;
}
.attachmentListItemCheckboxContainer,
.attachmentBulkActionsContainer,
.attachmentsBulkEditCheckbox {
display: none;
}
@supports selector(:has(*)) {
.dataBankAttachments:has(.attachmentsBulkEditCheckbox:checked) .attachmentsBulkEditButton {
color: var(--golden);
}
.dataBankAttachments:has(.attachmentsBulkEditCheckbox:checked) .attachmentBulkActionsContainer {
display: flex;
}
.dataBankAttachments:has(.attachmentsBulkEditCheckbox:checked) .attachmentListItemCheckboxContainer {
display: inline-flex;
}
.dataBankAttachments:has(.attachmentsBulkEditCheckbox:checked) .attachmentFileIcon {
display: none;
}
}

View File

@@ -0,0 +1,3 @@
<div data-i18n="Enter web URLs to scrape (one per line):">
Enter web URLs to scrape (one per line):
</div>

View File

@@ -0,0 +1,20 @@
<div>
<strong data-i18n="Enter a video URL to download its transcript.">
Enter a video URL or ID to download its transcript.
</strong>
<div data-i18n="Examples:" class="m-t-1">
Examples:
</div>
<ul class="justifyLeft">
<li>https://www.youtube.com/watch?v=jV1vkHv4zq8</li>
<li>https://youtu.be/nlLhw1mtCFA</li>
<li>TDpxx5UqrVU</li>
</ul>
<label>
Language code (optional 2-letter ISO code):
</label>
<input type="text" class="text_pole" name="youtubeLanguageCode" placeholder="e.g. en">
<label>
Video ID:
</label>
</div>

View File

@@ -0,0 +1,805 @@
import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js';
import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';
import { appendMediaToMessage, chat_metadata, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
import { getMessageTimeStamp } from '../../RossAscends-mods.js';
import { SECRET_KEYS, secret_state } from '../../secrets.js';
import { getMultimodalCaption } from '../shared.js';
import { textgen_types, textgenerationwebui_settings } from '../../textgen-settings.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
import { debounce_timeout, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR } from '../../constants.js';
export { MODULE_NAME };
const MODULE_NAME = 'caption';
const PROMPT_DEFAULT = 'What\'s 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-turbo';
}
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-turbo';
}
if (!extension_settings.caption.prompt) {
extension_settings.caption.prompt = PROMPT_DEFAULT;
}
if (!extension_settings.caption.template) {
extension_settings.caption.template = TEMPLATE_DEFAULT;
}
if (!extension_settings.caption.show_in_chat) {
extension_settings.caption.show_in_chat = false;
}
}
/**
* Sets an image icon for the send button.
*/
async function setImageIcon() {
try {
const sendButton = $('#send_picture .extensionsMenuExtensionButton');
sendButton.addClass('fa-image');
sendButton.removeClass('fa-hourglass-half');
}
catch (error) {
console.log(error);
}
}
/**
* Sets a spinner icon for the send button.
*/
async function setSpinnerIcon() {
try {
const sendButton = $('#send_picture .extensionsMenuExtensionButton');
sendButton.removeClass('fa-image');
sendButton.addClass('fa-hourglass-half');
}
catch (error) {
console.log(error);
}
}
/**
* Wraps a caption with a message template.
* @param {string} caption Raw caption
* @returns {Promise<string>} Wrapped caption
*/
async function wrapCaptionTemplate(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 = substituteParamsExtended(template, { caption: caption });
if (extension_settings.caption.refine_mode) {
messageText = await Popup.show.input(
'Review and edit the generated caption:',
'Press "Cancel" to abort the caption sending.',
messageText,
{ rows: 8, okButton: 'Send' });
if (!messageText) {
throw new Error('User aborted the caption sending.');
}
}
return messageText;
}
/**
* Appends caption to an existing message.
* @param {ChatMessage} message Message data
* @param {number} mediaIndex Index of the image to caption
* @returns {Promise<void>}
*/
async function captionExistingMessage(message, mediaIndex) {
if (!Array.isArray(message?.extra?.media) || message.extra.media.length === 0) {
return;
}
if (mediaIndex === undefined || isNaN(mediaIndex) || mediaIndex < 0 || mediaIndex >= message.extra.media.length) {
mediaIndex = 0;
}
const mediaAttachment = message.extra.media[mediaIndex];
if (!mediaAttachment || !mediaAttachment.url || mediaAttachment.type === MEDIA_TYPE.AUDIO) {
return;
}
if (mediaAttachment.type === MEDIA_TYPE.VIDEO && !isVideoCaptioningAvailable()) {
throw new Error('Captioning videos is not supported for the current source.');
}
const imageData = await fetch(mediaAttachment.url);
const blob = await imageData.blob();
const fileName = mediaAttachment.url.split('/').pop().split('?')[0] || 'image.jpg';
const file = new File([blob], fileName, { type: blob.type });
const caption = await getCaptionForFile(file, null, true);
if (!caption) {
console.warn('Failed to generate a caption for the image.');
return;
}
const wrappedCaption = await wrapCaptionTemplate(caption);
const messageText = String(message.mes).trim();
if (!messageText) {
message.extra.inline_image = false;
message.mes = wrappedCaption;
mediaAttachment.title = wrappedCaption;
mediaAttachment.captioned = true;
} else {
message.extra.inline_image = true;
mediaAttachment.append_title = true;
mediaAttachment.title = wrappedCaption;
mediaAttachment.captioned = true;
}
}
/**
* Sends a captioned message to the chat.
* @param {string} caption Caption text
* @param {string} image Image URL
* @param {string} mimeType Image MIME type
* @returns {Promise<void>}
*/
async function sendCaptionedMessage(caption, image, mimeType) {
const messageText = await wrapCaptionTemplate(caption);
const context = getContext();
/** @type {MediaAttachment} */
const mediaAttachment = {
url: image,
type: MEDIA_TYPE.getFromMime(mimeType) || MEDIA_TYPE.IMAGE,
title: messageText,
captioned: true,
source: MEDIA_SOURCE.CAPTIONED,
};
/** @type {ChatMessage} */
const message = {
name: context.name1,
is_user: true,
send_date: getMessageTimeStamp(),
mes: messageText,
extra: {
media: [mediaAttachment],
media_display: MEDIA_DISPLAY.GALLERY,
media_index: 0,
inline_image: !!extension_settings.caption.show_in_chat,
},
};
chat_metadata['tainted'] = true;
context.chat.push(message);
const messageId = context.chat.length - 1;
await eventSource.emit(event_types.MESSAGE_SENT, messageId);
context.addOneMessage(message);
await eventSource.emit(event_types.USER_MESSAGE_RENDERED, messageId);
await context.saveChat();
setTimeout(() => context.scrollOnMediaLoad(), debounce_timeout.short);
}
/**
* 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
* @param {string} externalPrompt Caption prompt
* @returns {Promise<{caption: string}>} Generated caption
*/
async function doCaptionRequest(base64Img, fileData, externalPrompt) {
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, externalPrompt);
default:
throw new Error('Unknown caption source.');
}
}
/**
* 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
* @param {string} externalPrompt Caption prompt
* @returns {Promise<{caption: string}>} Generated caption
*/
async function captionMultimodal(base64Img, externalPrompt) {
let prompt = externalPrompt || extension_settings.caption.prompt || PROMPT_DEFAULT;
if (!externalPrompt && extension_settings.caption.prompt_ask) {
const customPrompt = await callGenericPopup('Enter a comment or question:', POPUP_TYPE.INPUT, prompt, { rows: 4 });
if (!customPrompt) {
throw new Error('User aborted the caption sending.');
}
prompt = String(customPrompt).trim();
}
const caption = await getMultimodalCaption(base64Img, prompt);
return { caption };
}
/**
* Handles the image selection event.
* @param {Event} e Input event
* @param {string} prompt Caption prompt
* @param {boolean} quiet Suppresses sending a message
* @returns {Promise<string>} Generated caption
*/
async function onSelectImage(e, prompt, quiet) {
if (!(e.target instanceof HTMLInputElement)) {
return '';
}
const file = e.target.files[0];
const form = e.target.form;
if (!file || !(file instanceof File)) {
form && form.reset();
return '';
}
const caption = await getCaptionForFile(file, prompt, quiet);
form && form.reset();
return caption;
}
/**
* Gets a caption for an image file.
* @param {File} file Input file
* @param {string} prompt Caption prompt
* @param {boolean} quiet Suppresses sending a message
* @returns {Promise<string>} Generated caption
*/
async function getCaptionForFile(file, prompt, quiet) {
try {
if (file.type.startsWith('video/') && !isVideoCaptioningAvailable()) {
throw new Error('Video captioning is not available for the current source.');
}
setSpinnerIcon();
const context = getContext();
const fileData = await getBase64Async(await ensureImageFormatSupported(file));
const extension = getFileExtension(file);
const base64Data = fileData.split(',')[1];
const { caption } = await doCaptionRequest(base64Data, fileData, prompt);
if (!quiet) {
const imagePath = await saveBase64AsFile(base64Data, context.name2, '', extension);
await sendCaptionedMessage(caption, imagePath, file.type);
}
return caption;
}
catch (error) {
const errorMessage = error.message || 'Unknown error';
toastr.error(errorMessage, 'Failed to caption');
console.error(error);
return '';
}
finally {
setImageIcon();
}
}
function onRefineModeInput() {
extension_settings.caption.refine_mode = $('#caption_refine_mode').prop('checked');
saveSettingsDebounced();
}
/**
* Callback for the /caption command.
* @param {object} args Named parameters
* @param {string} prompt Caption prompt
*/
async function captionCommandCallback(args, prompt) {
const quiet = isTrueBoolean(args?.quiet);
const messageId = args?.mesId ?? args?.id;
const index = Number(args?.index ?? 0);
if (!isNaN(Number(messageId))) {
/** @type {ChatMessage} */
const message = getContext().chat[messageId];
if (Array.isArray(message?.extra?.media) && message.extra.media.length > 0) {
try {
const mediaAttachment = message.extra.media[index] || message.extra.media[0];
if (!mediaAttachment || !mediaAttachment.url) {
toastr.error('The specified message does not contain an image.');
return '';
}
if (mediaAttachment.type === MEDIA_TYPE.AUDIO) {
toastr.error('The specified media is an audio file. Captioning audio files is not supported.');
return '';
}
if (mediaAttachment.type === MEDIA_TYPE.VIDEO && !isVideoCaptioningAvailable()) {
toastr.error('The specified media is a video. Captioning videos is not supported for the current source.');
return '';
}
const fetchResult = await fetch(mediaAttachment.url);
const blob = await fetchResult.blob();
const fileName = mediaAttachment.url.split('/').pop().split('?')[0] || 'image.jpg';
const file = new File([blob], fileName, { type: blob.type });
return await getCaptionForFile(file, prompt, quiet);
} catch (error) {
toastr.error('Failed to get image from the message. Make sure the image is accessible.');
return '';
}
}
}
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*,video/*';
input.onchange = async (e) => {
const caption = await onSelectImage(e, prompt, quiet);
resolve(caption);
};
input.oncancel = () => resolve('');
input.click();
});
}
/**
* Checks if video captioning is available for the current source.
* @returns {boolean} True if video captioning is supported for the current source.
*/
function isVideoCaptioningAvailable() {
if (extension_settings.caption.source !== 'multimodal') {
return false;
}
return ['google', 'vertexai', 'zai'].includes(extension_settings.caption.multimodal_api);
}
jQuery(async function () {
function addSendPictureButton() {
const sendButton = $(`
<div id="send_picture" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-image extensionsMenuExtensionButton"></div>
<span data-i18n="Generate Caption">Generate Caption</span>
</div>`);
$('#caption_wand_container').append(sendButton);
$(sendButton).on('click', () => {
const hasCaptionModule = (() => {
const settings = extension_settings.caption;
// Handle non-multimodal sources
if (settings.source === 'extras' && modules.includes('caption')) return true;
if (settings.source === 'local' || settings.source === 'horde') return true;
// Handle multimodal sources
if (settings.source === 'multimodal') {
const api = settings.multimodal_api;
const altEndpointEnabled = settings.alt_endpoint_enabled;
const altEndpointUrl = settings.alt_endpoint_url;
// APIs that support reverse proxy
const reverseProxyApis = {
'openai': SECRET_KEYS.OPENAI,
'mistral': SECRET_KEYS.MISTRALAI,
'google': SECRET_KEYS.MAKERSUITE,
'vertexai': SECRET_KEYS.VERTEXAI,
'anthropic': SECRET_KEYS.CLAUDE,
'xai': SECRET_KEYS.XAI,
};
if (reverseProxyApis[api]) {
if (secret_state[reverseProxyApis[api]] || settings.allow_reverse_proxy) {
return true;
}
}
const chatCompletionApis = {
'openrouter': SECRET_KEYS.OPENROUTER,
'groq': SECRET_KEYS.GROQ,
'cohere': SECRET_KEYS.COHERE,
'aimlapi': SECRET_KEYS.AIMLAPI,
'moonshot': SECRET_KEYS.MOONSHOT,
'nanogpt': SECRET_KEYS.NANOGPT,
'chutes': SECRET_KEYS.CHUTES,
'electronhub': SECRET_KEYS.ELECTRONHUB,
'zai': SECRET_KEYS.ZAI,
};
if (chatCompletionApis[api] && secret_state[chatCompletionApis[api]]) {
return true;
}
const textCompletionApis = {
'ollama': textgen_types.OLLAMA,
'llamacpp': textgen_types.LLAMACPP,
'ooba': textgen_types.OOBA,
'koboldcpp': textgen_types.KOBOLDCPP,
'vllm': textgen_types.VLLM,
};
if (textCompletionApis[api] && altEndpointEnabled && altEndpointUrl) {
return true;
}
if (textCompletionApis[api] && !altEndpointEnabled && textgenerationwebui_settings.server_urls[textCompletionApis[api]]) {
return true;
}
// Custom API doesn't need additional checks
if (api === 'custom' || api === 'pollinations') {
return true;
}
}
return false;
})();
if (!hasCaptionModule) {
toastr.error('Choose other captioning source in the extension settings.', 'Captioning is not available');
return;
}
$('#img_file').trigger('click');
});
}
function addPictureSendForm() {
const imgInput = document.createElement('input');
imgInput.type = 'file';
imgInput.id = 'img_file';
imgInput.accept = 'image/*,video/*';
imgInput.hidden = true;
imgInput.addEventListener('change', (e) => onSelectImage(e, '', false));
const imgForm = document.createElement('form');
imgForm.id = 'img_form';
imgForm.appendChild(imgInput);
imgForm.hidden = true;
$('#form_sheld').append(imgForm);
}
async function switchMultimodalBlocks() {
await addRemoteEndpointModels();
const isMultimodal = extension_settings.caption.source === 'multimodal';
if (!extension_settings.caption.multimodal_model) {
const dropdown = $('#caption_multimodal_model');
const options = dropdown.find(`option[data-type="${extension_settings.caption.multimodal_api}"]`);
extension_settings.caption.multimodal_model = String(options.first().val());
}
$('#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_block [data-type]').each(function () {
const type = $(this).data('type');
const types = type.split(',');
$(this).toggle(types.includes(extension_settings.caption.multimodal_api));
});
}
async function addSettings() {
const html = await renderExtensionTemplateAsync('caption', 'settings', { TEMPLATE_DEFAULT, PROMPT_DEFAULT });
$('#caption_container').append(html);
}
async function addRemoteEndpointModels() {
async function processEndpoint(api, url) {
const dropdown = document.getElementById('caption_multimodal_model');
if (!(dropdown instanceof HTMLSelectElement)) {
return;
}
if (extension_settings.caption.source !== 'multimodal' || extension_settings.caption.multimodal_api !== api) {
return;
}
const options = Array.from(dropdown.options);
const response = await fetch(url, {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
});
if (!response.ok) {
return;
}
const modelIds = await response.json();
if (Array.isArray(modelIds) && modelIds.length > 0) {
modelIds.sort().forEach((modelId) => {
if (!modelId || typeof modelId !== 'string' || options.some(o => o.value === modelId)) {
return;
}
const option = document.createElement('option');
option.value = modelId;
option.textContent = modelId;
option.dataset.type = api;
dropdown.add(option);
});
}
}
await processEndpoint('openrouter', '/api/openrouter/models/multimodal');
await processEndpoint('aimlapi', '/api/backends/chat-completions/multimodal-models/aimlapi');
await processEndpoint('pollinations', '/api/backends/chat-completions/multimodal-models/pollinations');
await processEndpoint('nanogpt', '/api/backends/chat-completions/multimodal-models/nanogpt');
await processEndpoint('chutes', '/api/backends/chat-completions/multimodal-models/chutes');
await processEndpoint('electronhub', '/api/backends/chat-completions/multimodal-models/electronhub');
await processEndpoint('mistral', '/api/backends/chat-completions/multimodal-models/mistral');
await processEndpoint('xai', '/api/backends/chat-completions/multimodal-models/xai');
}
await addSettings();
addPictureSendForm();
addSendPictureButton();
setImageIcon();
migrateSettings();
await switchMultimodalBlocks();
$('#caption_refine_mode').prop('checked', !!(extension_settings.caption.refine_mode));
$('#caption_allow_reverse_proxy').prop('checked', !!(extension_settings.caption.allow_reverse_proxy));
$('#caption_prompt_ask').prop('checked', !!(extension_settings.caption.prompt_ask));
$('#caption_auto_mode').prop('checked', !!(extension_settings.caption.auto_mode));
$('#caption_source').val(extension_settings.caption.source);
$('#caption_prompt').val(extension_settings.caption.prompt);
$('#caption_template').val(extension_settings.caption.template);
$('#caption_refine_mode').on('input', onRefineModeInput);
$('#caption_source').on('change', async () => {
extension_settings.caption.source = String($('#caption_source').val());
await 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();
});
$('#caption_allow_reverse_proxy').on('input', () => {
extension_settings.caption.allow_reverse_proxy = $('#caption_allow_reverse_proxy').prop('checked');
saveSettingsDebounced();
});
$('#caption_prompt_ask').on('input', () => {
extension_settings.caption.prompt_ask = $('#caption_prompt_ask').prop('checked');
saveSettingsDebounced();
});
$('#caption_auto_mode').on('input', () => {
extension_settings.caption.auto_mode = !!$('#caption_auto_mode').prop('checked');
saveSettingsDebounced();
});
$('#caption_ollama_pull').on('click', (e) => {
const selectedModel = extension_settings.caption.multimodal_model;
const staticModels = { 'ollama_current': textgenerationwebui_settings.ollama_model, 'ollama_custom': extension_settings.caption.ollama_custom_model };
const presetModel = staticModels[selectedModel] || selectedModel;
e.preventDefault();
$('#ollama_download_model').trigger('click');
$('.popup .popup-input').val(presetModel);
});
$('#caption_multimodal_api').on('change', async () => {
const api = String($('#caption_multimodal_api').val());
extension_settings.caption.multimodal_api = api;
extension_settings.caption.multimodal_model = '';
await switchMultimodalBlocks();
saveSettingsDebounced();
});
$('#caption_multimodal_model').on('change', () => {
extension_settings.caption.multimodal_model = String($('#caption_multimodal_model').val());
saveSettingsDebounced();
});
$('#caption_altEndpoint_url').val(extension_settings.caption.alt_endpoint_url).on('input', () => {
extension_settings.caption.alt_endpoint_url = String($('#caption_altEndpoint_url').val());
saveSettingsDebounced();
});
$('#caption_altEndpoint_enabled').prop('checked', !!(extension_settings.caption.alt_endpoint_enabled)).on('input', () => {
extension_settings.caption.alt_endpoint_enabled = !!$('#caption_altEndpoint_enabled').prop('checked');
saveSettingsDebounced();
});
$('#caption_show_in_chat').prop('checked', !!(extension_settings.caption.show_in_chat)).on('input', () => {
extension_settings.caption.show_in_chat = !!$('#caption_show_in_chat').prop('checked');
saveSettingsDebounced();
});
$('#caption_ollama_custom_model').val(extension_settings.caption.ollama_custom_model || '').on('input', () => {
extension_settings.caption.ollama_custom_model = String($('#caption_ollama_custom_model').val()).trim();
saveSettingsDebounced();
});
$('#caption_refresh_models').on('click', async () => {
extension_settings.caption.multimodal_model = '';
await switchMultimodalBlocks();
saveSettingsDebounced();
});
const onMessageEvent = async (/** @type {number} */ messageId) => {
if (!extension_settings.caption.auto_mode) {
return;
}
const message = getContext().chat[messageId];
if (Array.isArray(message?.extra?.media) && message.extra.media.length > 0) {
for (let mediaIndex = 0; mediaIndex < message.extra.media.length; mediaIndex++) {
const mediaAttachment = message.extra.media[mediaIndex];
if (mediaAttachment.type === MEDIA_TYPE.VIDEO && !isVideoCaptioningAvailable()) {
continue;
}
if (mediaAttachment.type === MEDIA_TYPE.AUDIO) {
continue;
}
// Skip already captioned images and non-uploaded (generated, etc.) images
if (mediaAttachment.source !== MEDIA_SOURCE.UPLOAD || mediaAttachment.captioned) {
continue;
}
try {
await captionExistingMessage(message, mediaIndex);
} catch (e) {
console.error(`Auto-captioning failed for message ID ${messageId}, media index ${mediaIndex}`, e);
continue;
}
}
}
};
eventSource.on(event_types.MESSAGE_SENT, onMessageEvent);
eventSource.on(event_types.MESSAGE_FILE_EMBEDDED, onMessageEvent);
$(document).on('click', '.mes_img_caption', async function () {
const animationClass = 'fa-fade';
const messageBlock = $(this).closest('.mes');
const mediaContainer = $(this).closest('.mes_media_container');
const messageMedia = mediaContainer.find('.mes_img, .mes_video');
if (messageMedia.hasClass(animationClass)) return;
messageMedia.addClass(animationClass);
try {
const messageId = Number(messageBlock.attr('mesid'));
const mediaIndex = Number(mediaContainer.attr('data-index'));
const data = getContext().chat[messageId];
await captionExistingMessage(data, mediaIndex);
appendMediaToMessage(data, messageBlock, SCROLL_BEHAVIOR.KEEP);
await saveChatConditional();
} catch (e) {
console.error('Message image recaption failed', e);
toastr.error(e.message || 'Unknown error', 'Failed to caption');
} finally {
messageMedia.removeClass(animationClass);
}
});
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'caption',
callback: captionCommandCallback,
returns: 'caption',
namedArgumentList: [
new SlashCommandNamedArgument(
'quiet', 'suppress sending a captioned message', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
),
SlashCommandNamedArgument.fromProps({
name: 'mesId',
description: 'get image from a message with this ID',
typeList: [ARGUMENT_TYPE.NUMBER],
enumProvider: commonEnumProviders.messages(),
}),
SlashCommandNamedArgument.fromProps({
name: 'index',
description: 'index of the image in the message to caption (starting from 0)',
typeList: [ARGUMENT_TYPE.NUMBER],
enumProvider: commonEnumProviders.messageMedia(),
}),
],
unnamedArgumentList: [
new SlashCommandArgument(
'prompt', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Caption an image with an optional prompt and passes the caption down the pipe.
</div>
<div>
Only multimodal sources support custom prompts.
</div>
<div>
Provide a message ID to get an image from a message instead of uploading one.
</div>
<div>
Set the "quiet" argument to true to suppress sending a captioned message, default: false.
</div>
`,
}));
document.body.classList.add('caption');
});

View File

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

View File

@@ -0,0 +1,238 @@
<div class="caption_settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b data-i18n="Image Captioning">Image Captioning</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<label for="caption_source" data-i18n="Source">Source</label>
<select id="caption_source" class="text_pole">
<option value="local" data-i18n="Local">Local</option>
<option value="multimodal" data-i18n="Multimodal">Multimodal</option>
<option value="extras" data-i18n="Extras">Extras (deprecated)</option>
<option value="horde" data-i18n="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" data-i18n="API">API</label>
<select id="caption_multimodal_api" class="flex1 text_pole">
<option value="aimlapi">AI/ML API</option>
<option value="chutes">Chutes</option>
<option value="anthropic">Claude</option>
<option value="cohere">Cohere</option>
<option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>
<option value="electronhub">Electron Hub</option>
<option value="google">Google AI Studio</option>
<option value="vertexai">Google Vertex AI</option>
<option value="groq">Groq</option>
<option value="koboldcpp">KoboldCpp</option>
<option value="llamacpp">llama.cpp</option>
<option value="mistral">MistralAI</option>
<option value="moonshot">Moonshot AI</option>
<option value="nanogpt">NanoGPT</option>
<option value="ollama">Ollama</option>
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
<option value="ooba" data-i18n="Text Generation WebUI (oobabooga)">Text Generation WebUI (oobabooga)</option>
<option value="pollinations">Pollinations</option>
<option value="vllm">vLLM</option>
<option value="xai">xAI (Grok)</option>
<option value="zai">Z.AI (GLM)</option>
</select>
</div>
<div class="flex1 flex-container flexFlowColumn flexNoGap">
<label for="caption_multimodal_model" class="flex-container justifySpaceBetween">
<span data-i18n="Model">Model</span>
<div id="caption_refresh_models" class="right_menu_button margin0 padding0" title="Refresh model list" data-i18n="[title]Refresh model list">
<i class="fa-solid fa-sync"></i>
</div>
</label>
<select id="caption_multimodal_model" class="flex1 text_pole">
<!-- AI/ML API, OpenRouter, Pollinations, NanoGPT, Mistral, xAI are added externally by JavaScript -->
<option data-type="cohere" value="c4ai-aya-vision-8b">c4ai-aya-vision-8b</option>
<option data-type="cohere" value="c4ai-aya-vision-32b">c4ai-aya-vision-32b</option>
<option data-type="cohere" value="command-a-vision-07-2025">command-a-vision-07-2025</option>
<option data-type="moonshot" value="moonshot-v1-8k-vision-preview">moonshot-v1-8k-vision-preview</option>
<option data-type="moonshot" value="moonshot-v1-32k-vision-preview">moonshot-v1-32k-vision-preview</option>
<option data-type="moonshot" value="moonshot-v1-128k-vision-preview">moonshot-v1-128k-vision-preview</option>
<option data-type="openai" value="gpt-5.2">gpt-5.2</option>
<option data-type="openai" value="gpt-5.2-2025-12-11">gpt-5.2-2025-12-11</option>
<option data-type="openai" value="gpt-5.2-chat-latest">gpt-5.2-chat-latest</option>
<option data-type="openai" value="gpt-5.1">gpt-5.1</option>
<option data-type="openai" value="gpt-5.1-2025-11-13">gpt-5.1-2025-11-13</option>
<option data-type="openai" value="gpt-5.1-chat-latest">gpt-5.1-chat-latest</option>
<option data-type="openai" value="gpt-5">gpt-5</option>
<option data-type="openai" value="gpt-5-2025-08-07">gpt-5-2025-08-07</option>
<option data-type="openai" value="gpt-5-chat-latest">gpt-5-chat-latest</option>
<option data-type="openai" value="gpt-5-mini">gpt-5-mini</option>
<option data-type="openai" value="gpt-5-mini-2025-08-07">gpt-5-mini-2025-08-07</option>
<option data-type="openai" value="gpt-5-nano">gpt-5-nano</option>
<option data-type="openai" value="gpt-5-nano-2025-08-07">gpt-5-nano-2025-08-07</option>
<option data-type="openai" value="gpt-4.1">gpt-4.1</option>
<option data-type="openai" value="gpt-4.1-2025-04-14">gpt-4.1-2025-04-14</option>
<option data-type="openai" value="gpt-4.1-mini">gpt-4.1-mini</option>
<option data-type="openai" value="gpt-4.1-mini-2025-04-14">gpt-4.1-mini-2025-04-14</option>
<option data-type="openai" value="gpt-4.1-nano">gpt-4.1-nano</option>
<option data-type="openai" value="gpt-4.1-nano-2025-04-14">gpt-4.1-nano-2025-04-14</option>
<option data-type="openai" value="gpt-4-vision-preview">gpt-4-vision-preview</option>
<option data-type="openai" value="gpt-4-turbo">gpt-4-turbo</option>
<option data-type="openai" value="gpt-4o">gpt-4o</option>
<option data-type="openai" value="gpt-4o-mini">gpt-4o-mini</option>
<option data-type="openai" value="gpt-4o-mini-2024-07-18">gpt-4o-mini-2024-07-18</option>
<option data-type="openai" value="chatgpt-4o-latest">chatgpt-4o-latest</option>
<option data-type="openai" value="o1">o1</option>
<option data-type="openai" value="o1-2024-12-17">o1-2024-12-17</option>
<option data-type="openai" value="o3">o3</option>
<option data-type="openai" value="o3-2025-04-16">o3-2025-04-16</option>
<option data-type="openai" value="o4-mini">o4-mini</option>
<option data-type="openai" value="o4-mini-2025-04-16">o4-mini-2025-04-16</option>
<option data-type="openai" value="gpt-4.5-preview">gpt-4.5-preview</option>
<option data-type="openai" value="gpt-4.5-preview-2025-02-27">gpt-4.5-preview-2025-02-27</option>
<option data-type="anthropic" value="claude-opus-4-5">claude-opus-4-5</option>
<option data-type="anthropic" value="claude-opus-4-5-20251101">claude-opus-4-5-20251101</option>
<option data-type="anthropic" value="claude-sonnet-4-5">claude-sonnet-4-5</option>
<option data-type="anthropic" value="claude-sonnet-4-5-20250929">claude-sonnet-4-5-20250929</option>
<option data-type="anthropic" value="claude-haiku-4-5">claude-haiku-4-5</option>
<option data-type="anthropic" value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
<option data-type="anthropic" value="claude-opus-4-1">claude-opus-4-1</option>
<option data-type="anthropic" value="claude-opus-4-1-20250805">claude-opus-4-1-20250805</option>
<option data-type="anthropic" value="claude-opus-4-0">claude-opus-4-0</option>
<option data-type="anthropic" value="claude-opus-4-20250514">claude-opus-4-20250514</option>
<option data-type="anthropic" value="claude-sonnet-4-0">claude-sonnet-4-0</option>
<option data-type="anthropic" value="claude-sonnet-4-20250514">claude-sonnet-4-20250514</option>
<option data-type="anthropic" value="claude-3-7-sonnet-latest">claude-3-7-sonnet-latest</option>
<option data-type="anthropic" value="claude-3-7-sonnet-20250219">claude-3-7-sonnet-20250219</option>
<option data-type="anthropic" value="claude-3-5-sonnet-latest">claude-3-5-sonnet-latest</option>
<option data-type="anthropic" value="claude-3-5-sonnet-20241022">claude-3-5-sonnet-20241022</option>
<option data-type="anthropic" value="claude-3-5-sonnet-20240620">claude-3-5-sonnet-20240620</option>
<option data-type="anthropic" value="claude-3-5-haiku-latest">claude-3-5-haiku-latest</option>
<option data-type="anthropic" value="claude-3-5-haiku-20241022">claude-3-5-haiku-20241022</option>
<option data-type="anthropic" value="claude-3-opus-20240229">claude-3-opus-20240229</option>
<option data-type="anthropic" value="claude-3-haiku-20240307">claude-3-haiku-20240307</option>
<option data-type="google" value="gemini-3-pro-preview">gemini-3-pro-preview</option>
<option data-type="google" value="gemini-3-pro-image-preview">gemini-3-pro-image-preview</option>
<option data-type="google" value="gemini-3-flash-preview">gemini-3-flash-preview</option>
<option data-type="google" value="gemini-2.5-pro">gemini-2.5-pro</option>
<option data-type="google" value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>
<option data-type="google" value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
<option data-type="google" value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
<option data-type="google" value="gemini-2.5-flash">gemini-2.5-flash</option>
<option data-type="google" value="gemini-2.5-flash-preview-09-2025">gemini-2.5-flash-preview-09-2025</option>
<option data-type="google" value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
<option data-type="google" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
<option data-type="google" value="gemini-2.5-flash-lite-preview-09-2025">gemini-2.5-flash-lite-preview-09-2025</option>
<option data-type="google" value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>
<option data-type="google" value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
<option data-type="google" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
<option data-type="google" value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 2.5-exp-03-25</option>
<option data-type="google" value="gemini-2.0-pro-exp">gemini-2.0-pro-exp 2.5-exp-03-25</option>
<option data-type="google" value="gemini-exp-1206">gemini-exp-1206 2.5-exp-03-25</option>
<option data-type="google" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
<option data-type="google" value="gemini-2.0-flash-exp-image-generation">gemini-2.0-flash-exp-image-generation</option>
<option data-type="google" value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>
<option data-type="google" value="gemini-2.0-flash">gemini-2.0-flash</option>
<option data-type="google" value="gemini-2.0-flash-thinking-exp-01-21">gemini-2.0-flash-thinking-exp-01-21 2.5-flash-preview-5-20</option>
<option data-type="google" value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219 2.5-flash-preview-05-20</option>
<option data-type="google" value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp 2.5-flash-preview-05-20</option>
<option data-type="google" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
<option data-type="google" value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option>
<option data-type="google" value="gemini-2.0-flash-lite-preview">gemini-2.0-flash-lite-preview</option>
<option data-type="google" value="learnlm-2.0-flash-experimental">learnlm-2.0-flash-experimental</option>
<option data-type="google" value="gemini-robotics-er-1.5-preview">gemini-robotics-er-1.5-preview</option>
<option data-type="vertexai" value="gemini-3-pro-preview">gemini-3-pro-preview</option>
<option data-type="vertexai" value="gemini-3-pro-image-preview">gemini-3-pro-image-preview</option>
<option data-type="vertexai" value="gemini-3-flash-preview">gemini-3-flash-preview</option>
<option data-type="vertexai" value="gemini-2.5-pro">gemini-2.5-pro</option>
<option data-type="vertexai" value="gemini-2.5-flash">gemini-2.5-flash</option>
<option data-type="vertexai" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
<option data-type="vertexai" value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
<option data-type="vertexai" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
<option data-type="vertexai" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
<option data-type="vertexai" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
<option data-type="groq" value="meta-llama/llama-4-scout-17b-16e-instruct">meta-llama/llama-4-scout-17b-16e-instruct</option>
<option data-type="groq" value="meta-llama/llama-4-maverick-17b-128e-instruct">meta-llama/llama-4-maverick-17b-128e-instruct</option>
<option data-type="ollama" value="ollama_current" data-i18n="currently_selected">[Currently selected]</option>
<option data-type="ollama" value="ollama_custom" data-i18n="[Custom model]">[Custom model]</option>
<option data-type="ollama" value="bakllava">bakllava</option>
<option data-type="ollama" value="llava">llava</option>
<option data-type="ollama" value="llava-llama3">llava-llama3</option>
<option data-type="ollama" value="llava-phi3">llava-phi3</option>
<option data-type="ollama" value="moondream">moondream</option>
<option data-type="ollama" value="gemma3">gemma3</option>
<option data-type="ollama" value="minicpm-v">minicpm-v</option>
<option data-type="ollama" value="qwen2.5vl">qwen2.5vl</option>
<option data-type="ollama" value="granite3.2-vision">granite3.2-vision</option>
<option data-type="ollama" value="mistral-small3.1">mistral-small3.1</option>
<option data-type="ollama" value="mistral-small3.2">mistral-small3.2</option>
<option data-type="ollama" value="llama3.2-vision">llama3.2-vision</option>
<option data-type="ollama" value="llama4">llama4</option>
<option data-type="zai" value="glm-4.6v">glm-4.6v</option>
<option data-type="zai" value="glm-4.6v-flashx">glm-4.6v-flashx</option>
<option data-type="zai" value="glm-4.6v-flash">glm-4.6v-flash</option>
<option data-type="zai" value="glm-4.5v">glm-4.5v</option>
<option data-type="zai" value="autoglm-phone-multilingual">autoglm-phone-multilingual</option>
<option data-type="llamacpp" value="llamacpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="ooba" value="ooba_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
<option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option>
<option data-type="custom" value="custom_current" data-i18n="currently_selected">[Currently selected]</option>
</select>
</div>
<div data-type="ollama">
<div>
The model must be downloaded first! Do it with the <code>ollama pull</code> command or <a href="#" id="caption_ollama_pull">click here</a>.
</div>
<div class="marginTop5">
<label for="caption_ollama_custom_model">
<span data-i18n="Custom Model Tag">Custom Model Tag</span>
<small data-i18n="(for [Custom model] option)">(for [Custom model] option)</small>
</label>
<input id="caption_ollama_custom_model" class="text_pole" type="text" placeholder="e.g. gemma3:latest" />
</div>
</div>
<label data-type="openai,anthropic,google,vertexai,mistral,xai" class="checkbox_label flexBasis100p" for="caption_allow_reverse_proxy" title="Allow using reverse proxy if defined and valid.">
<input id="caption_allow_reverse_proxy" type="checkbox" class="checkbox">
<span data-i18n="Allow reverse proxy">Allow reverse proxy</span>
</label>
<div class="flexBasis100p marginBot10">
<small><b data-i18n="Hint:">Hint:</b> <span data-i18n="Set your API keys and endpoints in the 'API Connections' tab first.">Set your API keys and endpoints in the 'API Connections' tab first.</span></small>
</div>
<div data-type="koboldcpp,ollama,vllm,llamacpp,ooba" class="flex-container flexFlowColumn wide100p">
<label for="caption_altEndpoint_enabled" class="checkbox_label">
<input id="caption_altEndpoint_enabled" type="checkbox">
<span data-i18n="Use secondary URL">Use secondary URL</span>
</label>
<label for="caption_altEndpoint_url" data-i18n="Secondary captioning endpoint URL">
Secondary captioning endpoint URL
</label>
<input id="caption_altEndpoint_url" class="text_pole" type="text" placeholder="e.g. http://localhost:5001" />
</div>
</div>
<div id="caption_prompt_block">
<label for="caption_prompt" data-i18n="Caption Prompt">Caption Prompt</label>
<textarea id="caption_prompt" class="text_pole textarea_compact autoSetHeight" rows="1" placeholder="&lt; Use default &gt;">{{PROMPT_DEFAULT}}</textarea>
<label class="checkbox_label margin-bot-10px" for="caption_prompt_ask" title="Ask for a custom prompt every time an image is captioned.">
<input id="caption_prompt_ask" type="checkbox" class="checkbox">
<span data-i18n="Ask every time">Ask every time</span>
</label>
</div>
<label for="caption_template"><span data-i18n="Message Template">Message Template</span> <small><span data-i18n="(use _space">(use </span> <code>&lcub;&lcub;caption&rcub;&rcub;</code> <span data-i18n="macro)">macro)</span></small></label>
<textarea id="caption_template" class="text_pole textarea_compact autoSetHeight" rows="2" placeholder="&lt; Use default &gt;">{{TEMPLATE_DEFAULT}}</textarea>
<label class="checkbox_label" for="caption_auto_mode">
<input id="caption_auto_mode" type="checkbox" class="checkbox">
<span data-i18n="Automatically caption images">Automatically caption images</span>
<i class="fa-solid fa-info-circle" title="Automatically caption images when they are pasted into the chat or attached to messages."></i>
</label>
<label class="checkbox_label" for="caption_refine_mode">
<input id="caption_refine_mode" type="checkbox" class="checkbox">
<span data-i18n="Edit captions before saving">Edit captions before saving</span>
</label>
<label class="checkbox_label" for="caption_show_in_chat">
<input id="caption_show_in_chat" type="checkbox" class="checkbox">
<span data-i18n="Show captions in chat">Show captions in chat</span>
</label>
<div class="margin-bot-10px"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
#img_form {
display: none;
}

View File

@@ -0,0 +1,12 @@
<div>
<h3 data-i18n="Included settings:">Included settings:</h3>
<div class="justifyLeft flex-container flexFlowColumn flexNoGap">
{{#each settings}}
<label class="checkbox_label">
<input type="checkbox" value="{{@key}}" name="exclude"{{#if this}} checked{{/if}}>
<span data-i18n="{{@key}}">{{@key}}</span>
</label>
{{/each}}
</div>
<h3 data-i18n="Profile name:">Profile name:</h3>
</div>

View File

@@ -0,0 +1,827 @@
import { DOMPurify, Fuse } from '../../../lib.js';
import { event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';
import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
import { t } from '../../i18n.js';
import { getSecretLabelById } from '../../secrets.js';
const MODULE_NAME = 'connection-manager';
const NONE = '<None>';
const EMPTY = '<Empty>';
const DEFAULT_SETTINGS = {
profiles: [],
selectedProfile: null,
};
// Commands that can record an empty value into the profile
const ALLOW_EMPTY = [
'stop-strings',
'start-reply-with',
];
const CC_COMMANDS = [
'api',
'preset',
// Do not fix; CC needs to set the API twice because it could be overridden by the preset
'api',
'api-url',
'model',
'proxy',
'stop-strings',
'start-reply-with',
'reasoning-template',
'prompt-post-processing',
'secret-id',
'regex-preset',
];
const TC_COMMANDS = [
'api',
'preset',
'api-url',
'model',
'sysprompt',
'sysprompt-state',
'instruct',
'context',
'instruct-state',
'tokenizer',
'stop-strings',
'start-reply-with',
'reasoning-template',
'secret-id',
'regex-preset',
];
const FANCY_NAMES = {
'api': 'API',
'api-url': 'Server URL',
'preset': 'Settings Preset',
'model': 'Model',
'proxy': 'Proxy Preset',
'sysprompt-state': 'Use System Prompt',
'sysprompt': 'System Prompt Name',
'instruct-state': 'Instruct Mode',
'instruct': 'Instruct Template',
'context': 'Context Template',
'tokenizer': 'Tokenizer',
'stop-strings': 'Custom Stopping Strings',
'start-reply-with': 'Start Reply With',
'reasoning-template': 'Reasoning Template',
'prompt-post-processing': 'Prompt Post-Processing',
'secret-id': 'Secret',
'regex-preset': 'Regex Preset',
};
/**
* A wrapper for the connection manager spinner.
*/
class ConnectionManagerSpinner {
/**
* @type {AbortController[]}
*/
static abortControllers = [];
/** @type {HTMLElement} */
spinnerElement;
/** @type {AbortController} */
abortController = new AbortController();
constructor() {
// @ts-ignore
this.spinnerElement = document.getElementById('connection_profile_spinner');
this.abortController = new AbortController();
}
start() {
ConnectionManagerSpinner.abortControllers.push(this.abortController);
this.spinnerElement.classList.remove('hidden');
}
stop() {
this.spinnerElement.classList.add('hidden');
}
isAborted() {
return this.abortController.signal.aborted;
}
static abort() {
for (const controller of ConnectionManagerSpinner.abortControllers) {
controller.abort();
}
ConnectionManagerSpinner.abortControllers = [];
}
}
/**
* Get named arguments for the command callback.
* @param {object} [args] Additional named arguments
* @param {string} [args.force] Whether to force setting the value
* @returns {object} Named arguments
*/
function getNamedArguments(args = {}) {
// None of the commands here use underscored args, but better safe than sorry
return {
_scope: new SlashCommandScope(),
_abortController: new SlashCommandAbortController(),
_debugController: new SlashCommandDebugController(),
_parserFlags: {},
_hasUnnamedArgument: false,
quiet: 'true',
...args,
};
}
/** @type {() => SlashCommandEnumValue[]} */
const profilesProvider = () => [
new SlashCommandEnumValue(NONE),
...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
];
/**
* @typedef {Object} ConnectionProfile
* @property {string} id Unique identifier
* @property {string} mode Mode of the connection profile
* @property {string} [name] Name of the connection profile
* @property {string} [api] API
* @property {string} [preset] Settings Preset
* @property {string} [model] Model
* @property {string} [proxy] Proxy Preset
* @property {string} [instruct] Instruct Template
* @property {string} [context] Context Template
* @property {string} [instruct-state] Instruct Mode
* @property {string} [tokenizer] Tokenizer
* @property {string} [stop-strings] Custom Stopping Strings
* @property {string} [start-reply-with] Start Reply With
* @property {string} [reasoning-template] Reasoning Template
* @property {string} [prompt-post-processing] Prompt Post-Processing
* @property {string} [sysprompt] System Prompt Name
* @property {string} [sysprompt-state] Use System Prompt
* @property {string} [api-url] Server URL
* @property {string} [secret-id] Secret ID
* @property {string} [regex-preset] Regex Preset ID
* @property {string[]} [exclude] Commands to exclude
*/
/**
* Finds the best match for the search value.
* @param {string} value Search value
* @returns {ConnectionProfile|null} Best match or null
*/
function findProfileByName(value) {
// Try to find exact match
const profile = extension_settings.connectionManager.profiles.find(p => p.name === value);
if (profile) {
return profile;
}
// Try to find fuzzy match
const fuse = new Fuse(extension_settings.connectionManager.profiles, { keys: ['name'] });
const results = fuse.search(value);
if (results.length === 0) {
return null;
}
const bestMatch = results[0];
return bestMatch.item;
}
/**
* Reads the connection profile from the commands.
* @param {string} mode Mode of the connection profile
* @param {ConnectionProfile} profile Connection profile
* @param {boolean} [cleanUp] Whether to clean up the profile
*/
async function readProfileFromCommands(mode, profile, cleanUp = false) {
const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
const opposingCommands = mode === 'cc' ? TC_COMMANDS : CC_COMMANDS;
const excludeList = Array.isArray(profile.exclude) ? profile.exclude : [];
for (const command of commands) {
try {
if (excludeList.includes(command)) {
continue;
}
const allowEmpty = ALLOW_EMPTY.includes(command);
const args = getNamedArguments();
const result = await SlashCommandParser.commands[command].callback(args, '');
if (result || (allowEmpty && result === '')) {
profile[command] = result;
continue;
}
} catch (error) {
console.error(`Failed to execute command: ${command}`, error);
}
}
if (cleanUp) {
for (const command of commands) {
if (command.endsWith('-state') && profile[command] === 'false') {
delete profile[command.replace('-state', '')];
}
}
for (const command of opposingCommands) {
if (commands.includes(command)) {
continue;
}
delete profile[command];
}
}
}
/**
* Creates a new connection profile.
* @param {string} [forceName] Name of the connection profile
* @returns {Promise<ConnectionProfile>} Created connection profile
*/
async function createConnectionProfile(forceName = null) {
const mode = main_api === 'openai' ? 'cc' : 'tc';
const id = uuidv4();
/** @type {ConnectionProfile} */
const profile = {
id,
mode,
exclude: [],
};
await readProfileFromCommands(mode, profile);
const profileForDisplay = makeFancyProfile(profile);
const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'profile', { profile: profileForDisplay }));
template.find('input[name="exclude"]').on('input', function () {
const fancyName = String($(this).val());
const keyName = Object.entries(FANCY_NAMES).find(x => x[1] === fancyName)?.[0];
if (!keyName) {
console.warn('Key not found for fancy name:', fancyName);
return;
}
if (!Array.isArray(profile.exclude)) {
profile.exclude = [];
}
const excludeState = !$(this).prop('checked');
if (excludeState) {
profile.exclude.push(keyName);
} else {
const index = profile.exclude.indexOf(keyName);
index !== -1 && profile.exclude.splice(index, 1);
}
});
const isNameTaken = (n) => extension_settings.connectionManager.profiles.some(p => p.name === n);
const suggestedName = getUniqueName(collapseSpaces(`${profile.api ?? ''} ${profile.model ?? ''} - ${profile.preset ?? ''}`), isNameTaken);
let name = forceName ?? await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName);
// If it's cancelled, it will be false
if (!name) {
return null;
}
name = DOMPurify.sanitize(String(name));
if (!name) {
toastr.error('Name cannot be empty.');
return null;
}
if (isNameTaken(name) || name === NONE) {
toastr.error('A profile with the same name already exists.');
return null;
}
if (Array.isArray(profile.exclude)) {
for (const command of profile.exclude) {
delete profile[command];
}
}
profile.name = String(name);
return profile;
}
/**
* Deletes the selected connection profile.
* @returns {Promise<void>}
*/
async function deleteConnectionProfile() {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
if (!selectedProfile) {
return;
}
const index = extension_settings.connectionManager.profiles.findIndex(p => p.id === selectedProfile);
if (index === -1) {
return;
}
const profile = extension_settings.connectionManager.profiles[index];
const name = profile.name;
const confirm = await Popup.show.confirm(t`Are you sure you want to delete the selected profile?`, name);
if (!confirm) {
return;
}
extension_settings.connectionManager.profiles.splice(index, 1);
extension_settings.connectionManager.selectedProfile = null;
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_DELETED, profile);
}
/**
* Formats the connection profile for display.
* @param {ConnectionProfile} profile Connection profile
* @returns {Object} Fancy profile
*/
function makeFancyProfile(profile) {
return Object.entries(FANCY_NAMES).reduce((acc, [key, value]) => {
const allowEmpty = ALLOW_EMPTY.includes(key);
if (!profile[key]) {
if (profile[key] === '' && allowEmpty) {
acc[value] = EMPTY;
}
return acc;
}
// UUID is not very useful in the UI, so we replace it with a label (if available)
if (key === 'secret-id') {
const label = getSecretLabelById(profile[key]);
if (label) {
acc[value] = label;
return acc;
}
}
if (key === 'regex-preset') {
const label = extension_settings.regex_presets?.find(p => p.id === profile[key])?.name;
if (label) {
acc[value] = label;
return acc;
}
}
acc[value] = profile[key];
return acc;
}, {});
}
/**
* Applies the connection profile.
* @param {ConnectionProfile} profile Connection profile
* @returns {Promise<void>}
*/
async function applyConnectionProfile(profile) {
if (!profile) {
return;
}
// Abort any ongoing profile application
ConnectionManagerSpinner.abort();
const mode = profile.mode;
const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
const spinner = new ConnectionManagerSpinner();
spinner.start();
for (const command of commands) {
if (spinner.isAborted()) {
throw new Error('Profile application aborted');
}
const argument = profile[command];
const allowEmpty = ALLOW_EMPTY.includes(command);
if (!argument && !(allowEmpty && argument === '')) {
continue;
}
try {
const args = getNamedArguments(allowEmpty ? { force: 'true' } : {});
await SlashCommandParser.commands[command].callback(args, argument);
} catch (error) {
console.error(`Failed to execute command: ${command} ${argument}`, error);
}
}
spinner.stop();
}
/**
* Updates the selected connection profile.
* @param {ConnectionProfile} profile Connection profile
* @returns {Promise<void>}
*/
async function updateConnectionProfile(profile) {
profile.mode = main_api === 'openai' ? 'cc' : 'tc';
await readProfileFromCommands(profile.mode, profile, true);
}
/**
* Renders the connection profile details.
* @param {HTMLSelectElement} profiles Select element containing connection profiles
*/
function renderConnectionProfiles(profiles) {
profiles.innerHTML = '';
const noneOption = document.createElement('option');
noneOption.value = '';
noneOption.textContent = NONE;
noneOption.selected = !extension_settings.connectionManager.selectedProfile;
profiles.appendChild(noneOption);
for (const profile of extension_settings.connectionManager.profiles.sort((a, b) => a.name.localeCompare(b.name))) {
const option = document.createElement('option');
option.value = profile.id;
option.textContent = profile.name;
option.selected = profile.id === extension_settings.connectionManager.selectedProfile;
profiles.appendChild(option);
}
}
/**
* Renders the content of the details element.
* @param {HTMLElement} detailsContent Content element of the details
*/
async function renderDetailsContent(detailsContent) {
detailsContent.innerHTML = '';
if (detailsContent.classList.contains('hidden')) {
return;
}
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (profile) {
const profileForDisplay = makeFancyProfile(profile);
const templateParams = { profile: profileForDisplay };
if (Array.isArray(profile.exclude) && profile.exclude.length > 0) {
templateParams.omitted = profile.exclude.map(e => FANCY_NAMES[e]).join(', ');
}
const template = await renderExtensionTemplateAsync(MODULE_NAME, 'view', templateParams);
detailsContent.innerHTML = template;
} else {
detailsContent.textContent = t`No profile selected`;
}
}
(async function () {
extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (extension_settings.connectionManager[key] === undefined) {
extension_settings.connectionManager[key] = DEFAULT_SETTINGS[key];
}
}
const container = document.getElementById('rm_api_block');
const settings = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
container.insertAdjacentHTML('afterbegin', settings);
/** @type {HTMLSelectElement} */
// @ts-ignore
const profiles = document.getElementById('connection_profiles');
renderConnectionProfiles(profiles);
function toggleProfileSpecificButtons() {
const profileId = extension_settings.connectionManager.selectedProfile;
const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile'];
profileSpecificButtons.forEach(id => document.getElementById(id).classList.toggle('disabled', !profileId));
}
toggleProfileSpecificButtons();
profiles.addEventListener('change', async function () {
const selectedProfile = profiles.selectedOptions[0];
if (!selectedProfile) {
// Safety net for preventing the command getting stuck
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
return;
}
const profileId = selectedProfile.value;
extension_settings.connectionManager.selectedProfile = profileId;
saveSettingsDebounced();
await renderDetailsContent(detailsContent);
toggleProfileSpecificButtons();
// None option selected
if (!profileId) {
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
return;
}
const profile = extension_settings.connectionManager.profiles.find(p => p.id === profileId);
if (!profile) {
console.log(`Profile not found: ${profileId}`);
return;
}
await applyConnectionProfile(profile);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
});
const reloadButton = document.getElementById('reload_connection_profile');
reloadButton.addEventListener('click', async () => {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
console.log('No profile selected');
return;
}
await applyConnectionProfile(profile);
await renderDetailsContent(detailsContent);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
toastr.success('Connection profile reloaded', '', { timeOut: 1500 });
});
const createButton = document.getElementById('create_connection_profile');
createButton.addEventListener('click', async () => {
const profile = await createConnectionProfile();
if (!profile) {
return;
}
extension_settings.connectionManager.profiles.push(profile);
extension_settings.connectionManager.selectedProfile = profile.id;
saveSettingsDebounced();
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
});
const updateButton = document.getElementById('update_connection_profile');
updateButton.addEventListener('click', async () => {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
console.log('No profile selected');
return;
}
const oldProfile = structuredClone(profile);
await updateConnectionProfile(profile);
await renderDetailsContent(detailsContent);
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
toastr.success('Connection profile updated', '', { timeOut: 1500 });
});
const deleteButton = document.getElementById('delete_connection_profile');
deleteButton.addEventListener('click', async () => {
await deleteConnectionProfile();
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
});
const editButton = document.getElementById('edit_connection_profile');
editButton.addEventListener('click', async () => {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
console.log('No profile selected');
return;
}
if (!Array.isArray(profile.exclude)) {
profile.exclude = [];
}
let saveChanges = false;
const sortByViewOrder = (a, b) => Object.keys(FANCY_NAMES).indexOf(a) - Object.keys(FANCY_NAMES).indexOf(b);
const commands = profile.mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
const settings = commands.slice().sort(sortByViewOrder).reduce((acc, command) => {
const fancyName = FANCY_NAMES[command];
acc[fancyName] = !profile.exclude.includes(command);
return acc;
}, {});
const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'edit', { name: profile.name, settings }));
let newName = await callGenericPopup(template, POPUP_TYPE.INPUT, profile.name, {
customButtons: [{
text: t`Save and Update`,
classes: ['popup-button-ok'],
result: POPUP_RESULT.AFFIRMATIVE,
action: () => {
saveChanges = true;
},
}],
});
// If it's cancelled, it will be false
if (!newName) {
return;
}
newName = DOMPurify.sanitize(String(newName));
if (!newName) {
toastr.error('Name cannot be empty.');
return;
}
if (profile.name !== newName && extension_settings.connectionManager.profiles.some(p => p.name === newName)) {
toastr.error('A profile with the same name already exists.');
return;
}
const newExcludeList = template.find('input[name="exclude"]:not(:checked)').map(function () {
return Object.entries(FANCY_NAMES).find(x => x[1] === String($(this).val()))?.[0];
}).get();
const oldProfile = structuredClone(profile);
if (newExcludeList.length !== profile.exclude.length || !newExcludeList.every(e => profile.exclude.includes(e))) {
profile.exclude = newExcludeList;
for (const command of newExcludeList) {
delete profile[command];
}
if (saveChanges) {
await updateConnectionProfile(profile);
} else {
toastr.info('Press "Update" to record them into the profile.', 'Included settings list updated');
}
}
if (profile.name !== newName) {
toastr.success('Connection profile renamed.');
profile.name = newName;
}
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
});
/** @type {HTMLElement} */
const viewDetails = document.getElementById('view_connection_profile');
const detailsContent = document.getElementById('connection_profile_details_content');
viewDetails.addEventListener('click', async () => {
viewDetails.classList.toggle('active');
detailsContent.classList.toggle('hidden');
await renderDetailsContent(detailsContent);
});
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile',
helpString: 'Switch to a connection profile or return the name of the current profile in no argument is provided. Use <code>&lt;None&gt;</code> to switch to no profile.',
returns: 'name of the profile',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Name of the connection profile',
enumProvider: profilesProvider,
isRequired: false,
}),
],
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'await',
description: 'Wait for the connection profile to be applied before returning.',
isRequired: false,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
SlashCommandNamedArgument.fromProps({
name: 'timeout',
description: 'Maximum time to wait for the API connection to be established, in milliseconds. Set to 0 to disable. Only applies when await=true.',
isRequired: false,
typeList: [ARGUMENT_TYPE.NUMBER],
defaultValue: '2000',
}),
],
callback: async (args, value) => {
if (!value || typeof value !== 'string') {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
return NONE;
}
return profile.name;
}
if (value === NONE) {
profiles.selectedIndex = 0;
profiles.dispatchEvent(new Event('change'));
return NONE;
}
const profile = findProfileByName(value);
if (!profile) {
return '';
}
const shouldAwait = !isFalseBoolean(String(args?.await));
const awaitPromise = new Promise((resolve) => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
profiles.selectedIndex = Array.from(profiles.options).findIndex(o => o.value === profile.id);
profiles.dispatchEvent(new Event('change'));
if (shouldAwait) {
await awaitPromise;
// We should also await the connection to be established
const parsedTimeout = parseInt(args?.timeout?.toString());
const timeout = !isNaN(parsedTimeout) ? Math.max(0, parsedTimeout) : 2000;
if (timeout > 0) {
await waitUntilCondition(() => online_status !== 'no_connection', timeout, 100, { rejectOnTimeout: false });
}
}
return profile.name;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-list',
helpString: 'List all connection profile names.',
returns: 'list of profile names',
callback: () => JSON.stringify(extension_settings.connectionManager.profiles.map(p => p.name)),
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-create',
returns: 'name of the new profile',
helpString: 'Create a new connection profile using the current settings.',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'name of the new connection profile',
isRequired: true,
typeList: [ARGUMENT_TYPE.STRING],
}),
],
callback: async (_args, name) => {
if (!name || typeof name !== 'string') {
toastr.warning('Please provide a name for the new connection profile.');
return '';
}
const profile = await createConnectionProfile(name);
if (!profile) {
return '';
}
extension_settings.connectionManager.profiles.push(profile);
extension_settings.connectionManager.selectedProfile = profile.id;
saveSettingsDebounced();
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
return profile.name;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-update',
helpString: 'Update the selected connection profile.',
callback: async () => {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
toastr.warning('No profile selected.');
return '';
}
const oldProfile = structuredClone(profile);
await updateConnectionProfile(profile);
await renderDetailsContent(detailsContent);
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
return profile.name;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-get',
helpString: 'Get the details of the connection profile. Returns the selected profile if no argument is provided.',
returns: 'object of the selected profile',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Name of the connection profile',
enumProvider: profilesProvider,
isRequired: false,
}),
],
callback: async (_args, value) => {
if (!value || typeof value !== 'string') {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
return '';
}
return JSON.stringify(profile);
}
const profile = findProfileByName(value);
if (!profile) {
return '';
}
return JSON.stringify(profile);
},
}));
})();

View File

@@ -0,0 +1,11 @@
{
"display_name": "Connection Profiles",
"loading_order": 1,
"requires": [],
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "Cohee1207",
"version": "1.0.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}

View File

@@ -0,0 +1,22 @@
<div>
<h2 data-i18n="Creating a Connection Profile">
Creating a Connection Profile
</h2>
<div class="justifyLeft flex-container flexFlowColumn flexNoGap">
{{#each profile}}
<label class="checkbox_label">
<input type="checkbox" value="{{@key}}" name="exclude" checked>
<span><strong data-i18n="{{@key}}">{{@key}}:</strong>&nbsp;{{this}}</span>
</label>
{{/each}}
</div>
<div class="marginTop5">
<small>
<b data-i18n="Hint:">Hint:</b>
<i data-i18n="Click on the setting name to omit it from the profile.">Click on the setting name to omit it from the profile.</i>
</small>
</div>
<h3 data-i18n="Enter a name:">
Enter a name:
</h3>
</div>

View File

@@ -0,0 +1,21 @@
<div class="wide100p">
<div class="flex-container alignItemsBaseline">
<h3 class="margin0">
<span data-i18n="Connection Profile">Connection Profile</span>
<a href="https://docs.sillytavern.app/usage/core-concepts/connection-profiles" target="_blank" class="notes-link">
<span class="fa-solid fa-circle-question note-link-span"></span>
</a>
</h3>
<i id="connection_profile_spinner" class="fa-solid fa-spinner fa-spin hidden"></i>
</div>
<div class="flex-container">
<select class="text_pole flex1" id="connection_profiles"></select>
<i id="view_connection_profile" class="menu_button fa-solid fa-info-circle" title="View connection profile details" data-i18n="[title]View connection profile details"></i>
<i id="create_connection_profile" class="menu_button fa-solid fa-file-circle-plus" title="Create a new connection profile" data-i18n="[title]Create a new connection profile"></i>
<i id="update_connection_profile" class="menu_button fa-solid fa-save" title="Update a connection profile" data-i18n="[title]Update a connection profile"></i>
<i id="edit_connection_profile" class="menu_button fa-solid fa-pencil" title="Edit a connection profile" data-i18n="[title]Edit a connection profile"></i>
<i id="reload_connection_profile" class="menu_button fa-solid fa-recycle" title="Reload a connection profile" data-i18n="[title]Reload a connection profile"></i>
<i id="delete_connection_profile" class="menu_button fa-solid fa-trash-can" title="Delete a connection profile" data-i18n="[title]Delete a connection profile"></i>
</div>
<div id="connection_profile_details_content" class="hidden"></div>
</div>

View File

@@ -0,0 +1,11 @@
#connection_profile_details_content {
margin: 5px 0;
}
#connection_profile_details_content ul {
margin: 0;
}
#connection_profile_spinner {
margin-left: 5px;
}

View File

@@ -0,0 +1,10 @@
<ul>
{{#each profile}}
<li><strong data-i18n="{{@key}}">{{@key}}:</strong>&nbsp;{{this}}</li>
{{/each}}
</ul>
{{#if omitted}}
<div class="margin5">
<strong data-i18n="Omitted Settings:">Omitted Settings:</strong>&nbsp;<span>{{omitted}}</span>
</div>
{{/if}}

View File

@@ -0,0 +1,14 @@
<h3>
Enter a name for the custom expression:
</h3>
<h4>
Requirements:
</h4>
<ol class="justifyLeft">
<li>
The name must be unique and not already in use by the default expression.
</li>
<li>
The name must contain only letters, numbers, dashes and underscores. Don't include any file extensions.
</li>
</ol>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{{#each images}}
<div class="expression_list_item interactable" data-expression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}">
<div class="expression_list_buttons">
<div class="menu_button expression_list_upload" title="Upload image">
<i class="fa-solid fa-upload"></i>
</div>
<div class="menu_button expression_list_delete" title="Delete image">
<i class="fa-solid fa-trash"></i>
</div>
</div>
<div class="expression_list_title">
<span>{{../expression}}</span>
{{#if ../isCustom}}
<small class="expression_list_custom">(custom)</small>
{{/if}}
</div>
<div class="expression_list_image_container" title="{{this.title}}">
<img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
</div>
</div>
{{/each}}

View File

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

View File

@@ -0,0 +1,7 @@
<h3>
Are you sure you want to remove the expression <tt>&quot;{{expression}}&quot;</tt>?
</h3>
<div>
Uploaded images will not be deleted, but will no longer be used by the extension.
</div>
<br>

View File

@@ -0,0 +1,122 @@
<div class="expression_settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b data-i18n="Character Expressions">Character Expressions</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings." data-i18n="[title]Use the selected API from Chat Translation extension settings.">
<input id="expression_translate" type="checkbox">
<span data-i18n="Translate text to English before classification">Translate text to English before classification</span>
</label>
<label class="checkbox_label" for="expressions_allow_multiple" title="A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected." data-i18n="[title]A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.">
<input id="expressions_allow_multiple" type="checkbox">
<span data-i18n="Allow multiple sprites per expression">Allow multiple sprites per expression</span>
</label>
<label class="checkbox_label" for="expressions_reroll_if_same" title="If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned." data-i18n="[title]If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.">
<input id="expressions_reroll_if_same" type="checkbox">
<span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span>
</label>
<div class="expression_api_block m-b-1 m-t-1">
<label for="expression_api" data-i18n="Classifier API">Classifier API</label>
<small data-i18n="Select the API for classifying expressions.">Select the API for classifying expressions.</small>
<select id="expression_api" class="flex1 margin0">
<option value="99" data-i18n="[ None ]">[ None ]</option>
<option value="0" data-i18n="Local">Local</option>
<option value="1" data-i18n="Extras">Extras (deprecated)</option>
<option value="2" data-i18n="Main API">Main API</option>
<option value="3" data-i18n="WebLLM Extension">WebLLM Extension</option>
</select>
</div>
<div class="expression_llm_prompt_block m-b-1 m-t-1">
<label class="checkbox_label" for="expressions_filter_available" title="When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them." data-i18n="[title]When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.">
<input id="expressions_filter_available" type="checkbox">
<span data-i18n="Filter expressions for available sprites">Filter expressions for available sprites</span>
</label>
<label for="expression_llm_prompt" class="title_restorable m-t-1">
<span data-i18n="LLM Prompt">LLM Prompt</span>
<div id="expression_llm_prompt_restore" title="Restore default value" class="right_menu_button">
<i class="fa-solid fa-clock-rotate-left fa-sm"></i>
</div>
</label>
<small data-i18n="Used in addition to JSON schemas and function calling.">Used in addition to JSON schemas and function calling.</small>
<textarea id="expression_llm_prompt" type="text" class="text_pole textarea_compact autoSetHeight" rows="2" placeholder="Use &lcub;&lcub;labels&rcub;&rcub; special macro."></textarea>
</div>
<div class="expression_prompt_type_block flex-container flexFlowColumn">
<div data-i18n="LLM Prompt Strategy" class="title_restorable">
LLM Prompt Strategy
</div>
<label for="expression_prompt_raw" class="checkbox_label">
<input id="expression_prompt_raw" type="radio" name="expression_prompt_type" value="raw">
<span data-i18n="Limited Context">Limited Context</span>
</label>
<label for="expression_prompt_full" class="checkbox_label">
<input id="expression_prompt_full" type="radio" name="expression_prompt_type" value="full">
<span data-i18n="Full Context">Full Context</span>
</label>
</div>
<div class="expression_fallback_block m-b-1 m-t-1">
<label for="expression_fallback" data-i18n="Default / Fallback Expression">Default / Fallback Expression</label>
<small data-i18n="Set the default and fallback expression being used when no matching expression is found.">Set the default and fallback expression being used when no matching expression is found.</small>
<select id="expression_fallback" class="flex1 margin0"></select>
</div>
<div class="expression_custom_block m-b-1 m-t-1">
<label for="expression_custom" data-i18n="Custom Expressions">Custom Expressions</label>
<small><span data-i18n="Can be set manually or with an _space">Can be set manually or with an </span><tt>/emote</tt><span data-i18n="space_ slash command."> slash command.</span></small>
<div class="flex-container">
<select id="expression_custom" class="flex1 margin0"></select>
<i id="expression_custom_add" class="menu_button fa-solid fa-plus margin0" title="Add"></i>
<i id="expression_custom_remove" class="menu_button fa-solid fa-xmark margin0" title="Remove"></i>
</div>
</div>
<div id="no_chat_expressions" data-i18n="Open a chat to see the character expressions.">
Open a chat to see the character expressions.
</div>
<div id="open_chat_expressions">
<div class="offline_mode">
<small data-i18n="You are in offline mode. Click on the image below to set the expression.">You are in offline mode. Click on the image below to set the expression.</small>
</div>
<label for="expression_override" data-i18n="Sprite Folder Override">Sprite Folder Override</label>
<small><span data-i18n="Use a forward slash to specify a subfolder. Example: _space">Use a forward slash to specify a subfolder. Example: </span><tt>Bob/formal</tt></small>
<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 class="expression_buttons flex-container spaceEvenly">
<div id="expression_upload_pack_button" class="menu_button">
<i class="fa-solid fa-file-zipper"></i>
<span data-i18n="Upload sprite pack (ZIP)">Upload sprite pack (ZIP)</span>
</div>
<div id="expression_override_cleanup_button" class="menu_button">
<i class="fa-solid fa-trash-can"></i>
<span data-i18n="Remove all image overrides">Remove all image overrides</span>
</div>
</div>
<p class="hint">
<b data-i18n="Hint:">Hint:</b>
<i>
<span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
<span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt>
</i>
</p>
<p>
<i>
<span>In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a
dash.
Examples: </span><tt>joy.png</tt>, <tt>joy-1.png</tt>, <tt>joy.expressive.png</tt>
</i>
</p>
<h3 id="image_list_header">
<strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>
</h3>
<div id="image_list"></div>
</div>
</div>
</div>
<form>
<input type="file" id="expression_upload_pack" name="expression_upload_pack" accept="application/zip" hidden>
<input type="file" id="expression_upload" name="expression_upload" accept="image/*" hidden>
</form>
</div>

View File

@@ -0,0 +1,220 @@
.expression-helper {
display: inline-block;
height: 100%;
vertical-align: middle;
}
#expression-wrapper {
display: flex;
height: calc(100vh - var(--topBarBlockSize));
width: 100vw;
}
#visual-novel-wrapper {
display: flex;
height: calc(100vh - var(--topBarBlockSize));
width: 100vw;
overflow: hidden;
}
#visual-novel-wrapper .expression-holder {
width: max-content;
display: flex;
left: unset;
right: unset;
}
#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;
max-height: 90vh;
max-width: 90vh;
width: calc((100vw - var(--sheldWidth)) /2);
position: absolute;
bottom: 0;
padding: 0;
left: 0;
filter: drop-shadow(2px 2px 2px #51515199);
z-index: 2;
overflow: hidden;
resize: both;
display: flex;
justify-content: center;
}
#no_chat_expressions {
text-align: center;
margin: 10px 0;
font-weight: bold;
opacity: 0.8;
}
#image_list_header_name {
font-weight: 400;
}
img.expression {
min-width: 100px;
min-height: 100px;
max-height: 90vh;
max-width: 90vh;
top: 0;
bottom: 0;
padding: 0;
vertical-align: bottom;
object-fit: contain;
}
img.expression[src=""] {
visibility: hidden;
}
img.expression.default {
vertical-align: middle;
max-height: 120px;
object-fit: contain !important;
margin-top: 50px;
}
.expression-clone {
position: absolute;
}
.debug-image {
display: none;
visibility: collapse;
opacity: 0;
width: 0px;
height: 0px;
}
.expression_list_item {
position: relative;
max-width: 20%;
min-width: 100px;
max-height: 200px;
background-color: #515151b0;
border-radius: 10px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.expression_list_image_container {
overflow: hidden;
}
.expression_list_title {
position: absolute;
bottom: 0;
left: 0;
text-align: center;
font-weight: 600;
background-color: #000000a8;
width: 100%;
height: 20%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
line-height: 1;
}
.expression_list_custom {
font-size: 0.66rem;
}
.expression_list_buttons {
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
/* 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%;
object-fit: cover;
}
#image_list {
display: flex;
flex-direction: row;
column-gap: 1rem;
margin: 1rem;
flex-wrap: wrap;
justify-content: space-evenly;
row-gap: 1rem;
}
#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
color: green;
}
#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title {
color: darkolivegreen;
}
#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before {
content: '';
position: absolute;
top: -7px;
left: -9px;
font-size: 14px;
color: transparent;
text-shadow: 0 0 0 darkolivegreen;
}
#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title {
color: red;
}
.expression_settings label {
display: flex;
align-items: center;
flex-direction: row;
margin-left: 0px;
}
.expression_settings label input {
margin-left: 0px !important;
}
.expression_buttons .menu_button {
width: fit-content;
display: flex;
gap: 10px;
align-items: baseline;
flex-direction: row;
}
#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"],
#expressions_container:has(#expressions_allow_multiple:not(:checked)) label[for="expressions_reroll_if_same"] {
opacity: 0.3;
transition: opacity var(--animation-duration) ease;
}
#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:hover,
#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:focus {
opacity: unset;
}

View File

@@ -0,0 +1,12 @@
<div class="m-b-1" data-i18n="upload_expression_request">Please enter a name for the sprite (without extension).</div>
<div class="m-b-1" data-i18n="upload_expression_naming_1">
Sprite names must follow the naming schema for the selected expression: {{expression}}
</div>
<div data-i18n="upload_expression_naming_2">
For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'.
</div>
<span class="m-b-1" data-i18n="Examples:">Examples:</span> <tt>{{expression}}.png</tt>, <tt>{{expression}}-1.png</tt>, <tt>{{expression}}.expressive.png</tt>
{{#if clickedFileName}}
<div class="m-t-1" data-i18n="upload_expression_replace">Click 'Replace' to replace the existing expression:</div>
<tt>{{clickedFileName}}</tt>
{{/if}}

View File

@@ -0,0 +1,2 @@
<!-- I18n data for tools used to auto generate translations -->
<div data-i18n="Show Gallery">Show Gallery</div>

View File

@@ -0,0 +1,833 @@
import {
eventSource,
this_chid,
characters,
getRequestHeaders,
event_types,
animation_duration,
animation_easing,
} from '../../../script.js';
import { groups, selected_group } from '../../group-chats.js';
import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension, getVideoThumbnail, clamp } from '../../utils.js';
import { loadMovingUIState } from '../../power-user.js';
import { dragElement } from '../../RossAscends-mods.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { DragAndDropHandler } from '../../dragdrop.js';
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { t, translate } from '../../i18n.js';
import { Popup } from '../../popup.js';
import { deleteMediaFromServer } from '../../chats.js';
import { MEDIA_REQUEST_TYPE, VIDEO_EXTENSIONS } from '../../constants.js';
const isVideo = (/** @type {string} */ url) => VIDEO_EXTENSIONS.some(ext => new RegExp(`.${ext}$`, 'i').test(url));
const extensionName = 'gallery';
const extensionFolderPath = `scripts/extensions/${extensionName}/`;
let firstTime = true;
let deleteModeActive = false;
// Remove all draggables associated with the gallery
$('#movingDivs').on('click', '.dragClose', function () {
const relatedId = $(this).data('related-id');
if (!relatedId) return;
const relatedElement = $(`#movingDivs > .draggable[id="${relatedId}"]`);
relatedElement.transition({
opacity: 0,
duration: animation_duration,
easing: animation_easing,
complete: () => {
relatedElement.remove();
},
});
});
const CUSTOM_GALLERY_REMOVED_EVENT = 'galleryRemoved';
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach((node) => {
if (node instanceof HTMLElement && node.tagName === 'DIV' && node.id === 'gallery') {
eventSource.emit(CUSTOM_GALLERY_REMOVED_EVENT);
}
});
});
});
mutationObserver.observe(document.body, {
childList: true,
subtree: false,
});
const SORT = Object.freeze({
NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Name (A-Z)` },
NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Name (Z-A)` },
DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Newest` },
DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Oldest` },
});
const defaultSettings = Object.freeze({
folders: {},
sort: SORT.DATE_ASC.value,
});
/**
* Initializes the settings for the gallery extension.
*/
function initSettings() {
let shouldSave = false;
const context = SillyTavern.getContext();
if (!context.extensionSettings.gallery) {
context.extensionSettings.gallery = structuredClone(defaultSettings);
shouldSave = true;
}
for (const key of Object.keys(defaultSettings)) {
if (!Object.hasOwn(context.extensionSettings.gallery, key)) {
context.extensionSettings.gallery[key] = structuredClone(defaultSettings[key]);
shouldSave = true;
}
}
if (shouldSave) {
context.saveSettingsDebounced();
}
}
/**
* Retrieves the gallery folder for a given character.
* @param {Character} char Character data
* @returns {string} The gallery folder for the character
*/
function getGalleryFolder(char) {
return SillyTavern.getContext().extensionSettings.gallery.folders[char?.avatar] ?? char?.name;
}
/**
* Retrieves a list of gallery items based on a given URL. This function calls an API endpoint
* to get the filenames and then constructs the item list.
*
* @param {string} url - The base URL to retrieve the list of images.
* @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error.
*/
async function getGalleryItems(url) {
const sortValue = getSortOrder();
const sortObj = Object.values(SORT).find(it => it.value === sortValue) ?? SORT.DATE_ASC;
const response = await fetch('/api/images/list', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
folder: url,
sortField: sortObj.field,
sortOrder: sortObj.order,
type: MEDIA_REQUEST_TYPE.IMAGE | MEDIA_REQUEST_TYPE.VIDEO,
}),
});
url = await getSanitizedFilename(url);
const data = await response.json();
const items = [];
for (const file of data) {
const item = {
src: `user/images/${url}/${file}`,
srct: `user/images/${url}/${file}`,
title: '', // Optional title for each item
};
if (isVideo(file)) {
try {
// 150px of max height with some allowance for various aspect ratios
const maxSide = Math.round(150 * 1.5);
item.srct = await getVideoThumbnail(item.src, maxSide, maxSide);
} catch (error) {
console.error('Failed to generate video thumbnail for gallery:', error);
}
}
items.push(item);
}
return items;
}
/**
* Retrieves a list of gallery folders. This function calls an API endpoint
* @returns {Promise<string[]>} - Resolves with an array of gallery folders.
*/
async function getGalleryFolders() {
try {
const response = await fetch('/api/images/folders', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
});
if (!response.ok) {
throw new Error(`HTTP error. Status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to fetch gallery folders:', error);
return [];
}
}
/**
* Deletes a gallery item based on the provided URL.
* @param {string} url - The URL of the image to be deleted.
*/
async function deleteGalleryItem(url) {
const isDeleted = await deleteMediaFromServer(url, false);
if (isDeleted) {
toastr.success(t`Image deleted successfully.`);
}
}
/**
* Sets the sort order for the gallery.
* @param {string} order Sort order
*/
function setSortOrder(order) {
const context = SillyTavern.getContext();
context.extensionSettings.gallery.sort = order;
context.saveSettingsDebounced();
}
/**
* Retrieves the current sort order for the gallery.
* @returns {string} The current sort order for the gallery.
*/
function getSortOrder() {
return SillyTavern.getContext().extensionSettings.gallery.sort ?? defaultSettings.sort;
}
/**
* Initializes a gallery using the provided items and sets up the drag-and-drop functionality.
* It uses the nanogallery2 library to display the items and also initializes
* event listeners to handle drag-and-drop of files onto the gallery.
*
* @param {Array<Object>} items - An array of objects representing the items to display in the gallery.
* @param {string} url - The URL to use when a file is dropped onto the gallery for uploading.
* @returns {Promise<void>} - Promise representing the completion of the gallery initialization.
*/
async function initGallery(items, url) {
// Exposed defaults for future tweaking
const thumbnailHeight = 150;
const paginationVisiblePages = 5;
const paginationMaxLinesPerPage = 2;
const galleryMaxRows = clamp(Math.floor((window.innerHeight * 0.9 - 75) / thumbnailHeight), 1, 10);
const nonce = `nonce-${Math.random().toString(36).substring(2, 15)}`;
const gallery = $('#dragGallery');
gallery.addClass(nonce);
gallery.nanogallery2({
'items': items,
thumbnailWidth: 'auto',
thumbnailHeight: thumbnailHeight,
paginationVisiblePages: paginationVisiblePages,
paginationMaxLinesPerPage: paginationMaxLinesPerPage,
galleryMaxRows: galleryMaxRows,
galleryPaginationTopButtons: false,
galleryNavigationOverlayButtons: true,
galleryPaginationMode: 'rectangles',
galleryTheme: {
navigationBar: { background: 'none', borderTop: '', borderBottom: '', borderRight: '', borderLeft: '' },
navigationBreadcrumb: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' },
navigationFilter: { color: '#ddd', background: '#111', colorSelected: '#fff', backgroundSelected: '#111', borderRadius: '4px' },
navigationPagination: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' },
thumbnail: { background: '#444', backgroundImage: 'linear-gradient(315deg, #111 0%, #445 90%)', borderColor: '#000', borderRadius: '0px', labelOpacity: 1, labelBackground: 'rgba(34, 34, 34, 0)', titleColor: '#fff', titleBgColor: 'transparent', titleShadow: '', descriptionColor: '#ccc', descriptionBgColor: 'transparent', descriptionShadow: '', stackBackground: '#aaa' },
thumbnailIcon: { padding: '5px', color: '#fff', shadow: '' },
pagination: { background: '#181818', backgroundSelected: '#666', color: '#fff', borderRadius: '2px', shapeBorder: '3px solid var(--SmartThemeQuoteColor)', shapeColor: '#444', shapeSelectedColor: '#aaa' },
},
galleryDisplayMode: 'pagination',
fnThumbnailOpen: viewWithDragbox,
fnThumbnailInit: function (/** @type {JQuery<HTMLElement>} */ $thumbnail, /** @type {{src: string}} */ item) {
if (!item?.src) return;
$thumbnail.attr('title', String(item.src).split('/').pop());
},
});
const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files) => {
if (!Array.isArray(files) || files.length === 0) {
return;
}
// Upload each file
for (const file of files) {
await uploadFile(file, url);
}
// Refresh the gallery
const newItems = await getGalleryItems(url);
$('#dragGallery').closest('#gallery').remove();
await makeMovable(url);
await delay(100);
await initGallery(newItems, url);
});
const resizeHandler = function () {
gallery.nanogallery2('resize');
};
eventSource.on('resizeUI', resizeHandler);
eventSource.once(event_types.CHAT_CHANGED, function () {
gallery.closest('#gallery').remove();
});
eventSource.once(CUSTOM_GALLERY_REMOVED_EVENT, function () {
gallery.nanogallery2('destroy');
dragDropHandler.destroy();
eventSource.removeListener('resizeUI', resizeHandler);
});
// Set dropzone height to be the same as the parent
gallery.css('height', gallery.parent().css('height'));
//let images populate first
await delay(100);
//unset the height (which must be getting set by the gallery library at some point)
gallery.css('height', 'unset');
//force a resize to make images display correctly
gallery.nanogallery2('resize');
}
/**
* Displays a character gallery using the nanogallery2 library.
*
* This function takes care of:
* - Loading necessary resources for the gallery on the first invocation.
* - Preparing gallery items based on the character or group selection.
* - Handling the drag-and-drop functionality for image upload.
* - Displaying the gallery in a popup.
* - Cleaning up resources when the gallery popup is closed.
*
* @returns {Promise<void>} - Promise representing the completion of the gallery display process.
*/
async function showCharGallery(deleteModeState = false) {
// Load necessary files if it's the first time calling the function
if (firstTime) {
await loadFileToDocument(
`${extensionFolderPath}nanogallery2.woff.min.css`,
'css',
);
await loadFileToDocument(
`${extensionFolderPath}jquery.nanogallery2.min.js`,
'js',
);
firstTime = false;
toastr.info('Images can also be found in the folder `user/images`', 'Drag and drop images onto the gallery to upload them', { timeOut: 6000 });
}
try {
deleteModeActive = deleteModeState;
let url = selected_group || this_chid;
if (!selected_group && this_chid !== undefined) {
url = getGalleryFolder(characters[this_chid]);
}
const items = await getGalleryItems(url);
// if there already is a gallery, destroy it and place this one in its place
$('#dragGallery').closest('#gallery').remove();
await makeMovable(url);
await delay(100);
await initGallery(items, url);
} catch (err) {
console.error(err);
}
}
/**
* Uploads a given file to a specified URL.
* Once the file is uploaded, it provides a success message using toastr,
* destroys the existing gallery, fetches the latest items, and reinitializes the gallery.
*
* @param {File} file - The file object to be uploaded.
* @param {string} url - The URL indicating where the file should be uploaded.
* @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh.
*/
async function uploadFile(file, url) {
try {
// Convert the file to a base64 string
const fileBase64 = await getBase64Async(file);
const base64Data = fileBase64.split(',')[1];
const extension = getFileExtension(file);
const path = await saveBase64AsFile(base64Data, url, '', extension);
toastr.success(t`File uploaded successfully. Saved at: ${path}`);
} catch (error) {
console.error('There was an issue uploading the file:', error);
// Replacing alert with toastr error notification
toastr.error(t`Failed to upload the file.`);
}
}
/**
* Creates a new draggable container based on a template.
* This function takes a template with the ID 'generic_draggable_template' and clones it.
* The cloned element has its attributes set, a new child div appended, and is made visible on the body.
* Additionally, it sets up the element to prevent dragging on its images.
* @param {string} url - The URL of the image source.
* @returns {Promise<void>} - Promise representing the completion of the draggable container creation.
*/
async function makeMovable(url) {
console.debug('making new container from template');
const id = 'gallery';
const template = $('#generic_draggable_template').html();
const newElement = $(template);
newElement.css({ 'background-color': 'var(--SmartThemeBlurTintColor)', 'opacity': 0 });
newElement.attr('forChar', id);
newElement.attr('id', id);
newElement.find('.drag-grabber').attr('id', `${id}header`);
const dragTitle = newElement.find('.dragTitle');
dragTitle.addClass('flex-container justifySpaceBetween alignItemsBaseline');
const titleText = document.createElement('span');
titleText.textContent = t`Image Gallery`;
dragTitle.append(titleText);
// Create a container for the controls
const controlsContainer = document.createElement('div');
controlsContainer.classList.add('flex-container', 'alignItemsCenter');
const sortSelect = document.createElement('select');
sortSelect.classList.add('gallery-sort-select');
for (const sort of Object.values(SORT)) {
const option = document.createElement('option');
option.value = sort.value;
option.textContent = sort.label;
sortSelect.appendChild(option);
}
sortSelect.addEventListener('change', async () => {
const selectedOption = sortSelect.options[sortSelect.selectedIndex].value;
setSortOrder(selectedOption);
closeButton.trigger('click');
await showCharGallery();
});
sortSelect.value = getSortOrder();
controlsContainer.appendChild(sortSelect);
// Create the "Add Image" button
const addImageButton = document.createElement('div');
addImageButton.classList.add('menu_button', 'menu_button_icon', 'interactable');
addImageButton.title = 'Add Image';
addImageButton.innerHTML = '<i class="fa-solid fa-plus fa-fw"></i><div>Add Image</div>';
// Create a hidden file input
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*,video/*';
fileInput.multiple = true;
fileInput.style.display = 'none';
// Trigger file input when the button is clicked
addImageButton.addEventListener('click', () => {
fileInput.click();
});
// Handle file selection
fileInput.addEventListener('change', async () => {
const files = fileInput.files;
if (files.length > 0) {
for (const file of files) {
await uploadFile(file, url);
}
// Refresh the gallery
closeButton.trigger('click');
await showCharGallery();
}
});
controlsContainer.appendChild(addImageButton);
dragTitle.append(controlsContainer);
newElement.append(fileInput); // Append hidden file input to the main element
// add no-scrollbar class to this element
newElement.addClass('no-scrollbar');
// get the close button and set its id and data-related-id
const closeButton = newElement.find('.dragClose');
closeButton.attr('id', `${id}close`);
closeButton.attr('data-related-id', `${id}`);
const topBarElement = document.createElement('div');
topBarElement.classList.add('flex-container', 'alignItemsCenter');
const onChangeFolder = async (/** @type {Event} */ e) => {
if (e instanceof KeyboardEvent && e.key !== 'Enter') {
return;
}
try {
const newUrl = await getSanitizedFilename(galleryFolderInput.value);
updateGalleryFolder(newUrl);
closeButton.trigger('click');
await showCharGallery();
toastr.info(t`Gallery folder changed to ${newUrl}`);
galleryFolderInput.value = newUrl;
} catch (error) {
console.error('Failed to change gallery folder:', error);
toastr.error(error?.message || t`Unknown error`, t`Failed to change gallery folder`);
}
};
const onRestoreFolder = async () => {
try {
restoreGalleryFolder();
closeButton.trigger('click');
await showCharGallery();
} catch (error) {
console.error('Failed to restore gallery folder:', error);
toastr.error(error?.message || t`Unknown error`, t`Failed to restore gallery folder`);
}
};
const galleryFolderInput = document.createElement('input');
galleryFolderInput.type = 'text';
galleryFolderInput.placeholder = t`Folder Name`;
galleryFolderInput.title = t`Enter a folder name to change the gallery folder`;
galleryFolderInput.value = url;
galleryFolderInput.classList.add('text_pole', 'gallery-folder-input', 'flex1');
galleryFolderInput.addEventListener('keyup', onChangeFolder);
const galleryFolderAccept = document.createElement('div');
galleryFolderAccept.classList.add('right_menu_button', 'fa-solid', 'fa-check', 'fa-fw');
galleryFolderAccept.title = t`Change gallery folder`;
galleryFolderAccept.addEventListener('click', onChangeFolder);
const galleryDeleteMode = document.createElement('div');
galleryDeleteMode.classList.add('right_menu_button', 'fa-solid', 'fa-trash', 'fa-fw');
galleryDeleteMode.classList.toggle('warning', deleteModeActive);
galleryDeleteMode.title = t`Delete mode`;
galleryDeleteMode.addEventListener('click', () => {
deleteModeActive = !deleteModeActive;
galleryDeleteMode.classList.toggle('warning', deleteModeActive);
if (deleteModeActive) {
toastr.info(t`Delete mode is ON. Click on images you want to delete.`);
}
});
const galleryFolderRestore = document.createElement('div');
galleryFolderRestore.classList.add('right_menu_button', 'fa-solid', 'fa-recycle', 'fa-fw');
galleryFolderRestore.title = t`Restore gallery folder`;
galleryFolderRestore.addEventListener('click', onRestoreFolder);
topBarElement.appendChild(galleryFolderInput);
topBarElement.appendChild(galleryFolderAccept);
topBarElement.appendChild(galleryDeleteMode);
topBarElement.appendChild(galleryFolderRestore);
newElement.append(topBarElement);
// Populate the gallery folder input with a list of available folders
const folders = await getGalleryFolders();
$(galleryFolderInput)
.autocomplete({
source: (i, o) => {
const term = i.term.toLowerCase();
const filtered = folders.filter(f => f.toLowerCase().includes(term));
o(filtered);
},
select: (e, u) => {
galleryFolderInput.value = u.item.value;
onChangeFolder(e);
},
minLength: 0,
})
.on('focus', () => $(galleryFolderInput).autocomplete('search', ''));
//add a div for the gallery
newElement.append('<div id="dragGallery"></div>');
$('#dragGallery').css('display', 'block');
$('#movingDivs').append(newElement);
loadMovingUIState();
$(`.draggable[forChar="${id}"]`).css('display', 'block');
dragElement(newElement);
newElement.transition({
opacity: 1,
duration: animation_duration,
easing: animation_easing,
});
$(`.draggable[forChar="${id}"] img`).on('dragstart', (e) => {
console.log('saw drag on avatar!');
e.preventDefault();
return false;
});
}
/**
* Sets the gallery folder to a new URL.
* @param {string} newUrl - The new URL to set for the gallery folder.
*/
function updateGalleryFolder(newUrl) {
if (!newUrl) {
throw new Error('Folder name cannot be empty');
}
const context = SillyTavern.getContext();
if (context.groupId) {
throw new Error('Cannot change gallery folder in group chat');
}
if (context.characterId === undefined) {
throw new Error('Character is not selected');
}
const avatar = context.characters[context.characterId]?.avatar;
const name = context.characters[context.characterId]?.name;
if (!avatar) {
throw new Error('Character PNG ID is not found');
}
if (newUrl === name) {
// Default folder name is picked, remove the override
delete context.extensionSettings.gallery.folders[avatar];
} else {
// Custom folder name is provided, set the override
context.extensionSettings.gallery.folders[avatar] = newUrl;
}
context.saveSettingsDebounced();
}
/**
* Restores the gallery folder to the default value.
*/
function restoreGalleryFolder() {
const context = SillyTavern.getContext();
if (context.groupId) {
throw new Error('Cannot change gallery folder in group chat');
}
if (context.characterId === undefined) {
throw new Error('Character is not selected');
}
const avatar = context.characters[context.characterId]?.avatar;
if (!avatar) {
throw new Error('Character PNG ID is not found');
}
const existingOverride = context.extensionSettings.gallery.folders[avatar];
if (!existingOverride) {
throw new Error('No folder override found');
}
delete context.extensionSettings.gallery.folders[avatar];
context.saveSettingsDebounced();
}
/**
* Creates a new draggable image based on a template.
*
* This function clones a provided template with the ID 'generic_draggable_template',
* appends the given image URL, ensures the element has a unique ID,
* and attaches the element to the body. After appending, it also prevents
* dragging on the appended image.
*
* @param {string} id - A base identifier for the new draggable element.
* @param {string} url - The URL of the image to be added to the draggable element.
*/
function makeDragImg(id, url) {
// Step 1: Clone the template content
const template = document.getElementById('generic_draggable_template');
if (!(template instanceof HTMLTemplateElement)) {
console.error('The element is not a <template> tag');
return;
}
const newElement = document.importNode(template.content, true);
// Step 2: Append the given image
const mediaElement = isVideo(url)
? document.createElement('video')
: document.createElement('img');
mediaElement.src = url;
if (mediaElement instanceof HTMLVideoElement) {
mediaElement.controls = true;
mediaElement.autoplay = true;
}
let uniqueId = `draggable_${id}`;
const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable'));
if (draggableElem) {
draggableElem.appendChild(mediaElement);
// Find a unique id for the draggable element
let counter = 1;
while (document.getElementById(uniqueId)) {
uniqueId = `draggable_${id}_${counter}`;
counter++;
}
draggableElem.id = uniqueId;
// Add the galleryImageDraggable to have unique class
draggableElem.classList.add('galleryImageDraggable');
// Ensure that the newly added element is displayed as block
draggableElem.style.display = 'block';
//and has no padding unlike other non-zoomed-avatar draggables
draggableElem.style.padding = '0';
// Add an id to the close button
// If the close button exists, set related-id
const closeButton = /** @type {HTMLElement} */ (draggableElem.querySelector('.dragClose'));
if (closeButton) {
closeButton.id = `${uniqueId}close`;
closeButton.dataset.relatedId = uniqueId;
}
// Find the .drag-grabber and set its matching unique ID
const dragGrabber = draggableElem.querySelector('.drag-grabber');
if (dragGrabber) {
dragGrabber.id = `${uniqueId}header`; // appending _header to make it match the parent's unique ID
}
}
// Step 3: Attach it to the movingDivs container
document.getElementById('movingDivs').appendChild(newElement);
// Step 4: Call dragElement and loadMovingUIState
const appendedElement = document.getElementById(uniqueId);
if (appendedElement) {
var elmntName = $(appendedElement);
loadMovingUIState();
dragElement(elmntName);
// Prevent dragging the image
$(`#${uniqueId} img`).on('dragstart', (e) => {
console.log('saw drag on avatar!');
e.preventDefault();
return false;
});
} else {
console.error('Failed to append the template content or retrieve the appended content.');
}
}
/**
* Sanitizes a given ID to ensure it can be used as an HTML ID.
* This function replaces spaces and non-word characters with dashes.
* It also removes any non-ASCII characters.
* @param {string} id - The ID to be sanitized.
* @returns {string} - The sanitized ID.
*/
function sanitizeHTMLId(id) {
// Replace spaces and non-word characters
id = id.replace(/\s+/g, '-')
.replace(/[^\x00-\x7F]/g, '-')
.replace(/\W/g, '');
return id;
}
/**
* Processes a list of items (containing URLs) and creates a draggable box for the first item.
*
* If the provided list of items is non-empty, it takes the URL of the first item,
* derives an ID from the URL, and uses the makeDragImg function to create
* a draggable image element based on that ID and URL.
*
* @param {Array} items - A list of items where each item has a responsiveURL method that returns a URL.
*/
function viewWithDragbox(items) {
if (items && items.length > 0) {
const url = items[0].responsiveURL(); // Get the URL of the clicked image/video
if (deleteModeActive) {
Popup.show.confirm(t`Are you sure you want to delete this image?`, url)
.then(async (confirmed) => {
if (!confirmed) {
return;
}
deleteGalleryItem(url).then(() => showCharGallery(deleteModeActive));
});
} else {
// ID should just be the last part of the URL, removing the extension
const id = sanitizeHTMLId(url.substring(url.lastIndexOf('/') + 1, url.lastIndexOf('.')));
makeDragImg(id, url);
}
}
}
// Registers a simple command for opening the char gallery.
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'show-gallery',
aliases: ['sg'],
callback: () => {
showCharGallery();
return '';
},
helpString: 'Shows the gallery.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'list-gallery',
aliases: ['lg'],
callback: listGalleryCommand,
returns: 'list of images',
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'char',
description: 'character name',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: commonEnumProviders.characters('character'),
}),
SlashCommandNamedArgument.fromProps({
name: 'group',
description: 'group name',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: commonEnumProviders.characters('group'),
}),
],
helpString: 'List images in the gallery of the current char / group or a specified char / group.',
}));
async function listGalleryCommand(args) {
try {
let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid);
if (!args.char && !args.group && !selected_group && this_chid !== undefined) {
url = getGalleryFolder(characters[this_chid]);
}
const items = await getGalleryItems(url);
return JSON.stringify(items.map(it => it.src));
} catch (err) {
console.error(err);
}
return JSON.stringify([]);
}
// On extension load, ensure the settings are initialized
(function () {
initSettings();
eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => {
const context = SillyTavern.getContext();
const galleryFolder = context.extensionSettings.gallery.folders[oldAvatar];
if (galleryFolder) {
context.extensionSettings.gallery.folders[newAvatar] = galleryFolder;
delete context.extensionSettings.gallery.folders[oldAvatar];
context.saveSettingsDebounced();
}
});
eventSource.on(event_types.CHARACTER_DELETED, (data) => {
const avatar = data?.character?.avatar;
if (!avatar) return;
const context = SillyTavern.getContext();
delete context.extensionSettings.gallery.folders[avatar];
context.saveSettingsDebounced();
});
eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (selectedOptionId) => {
if (selectedOptionId === 'show_char_gallery') {
showCharGallery();
}
});
// Add an option to the dropdown
$('#char-management-dropdown').append(
$('<option>', {
id: 'show_char_gallery',
text: translate('Show Gallery'),
}),
);
})();

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
{
"display_name": "Gallery",
"loading_order": 6,
"requires": [],
"optional": [
],
"js": "index.js",
"css": "style.css",
"author": "City-Unit",
"version": "1.5.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
.nGY2 .nGY2GalleryBottom {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.gallery-folder-input {
background-color: transparent;
font-size: calc(var(--mainFontSize)* 0.9);
opacity: 0.8;
flex-grow: 1;
}
.gallery-folder-input:placeholder-shown {
font-style: italic;
opacity: 0.5;
border-color: transparent;
}
.gallery-sort-select {
width: max-content;
flex: 1;
cursor: pointer;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
opacity: 0.8;
background-color: var(--black30a);
border: 1px solid var(--SmartThemeBorderColor);
background-image: url(/img/down-arrow.svg);
background-repeat: no-repeat;
background-position: right 6px center;
background-size: 8px 5px;
padding-right: 20px;
margin-bottom: 0;
}
#gallery .dragTitle {
margin-right: 30px;
}
#dragGallery {
min-height: 25dvh;
}
#gallery .right_menu_button.warning {
opacity: 1;
filter: unset;
}
.galleryImageDraggable video {
width: 100%;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,148 @@
<div id="memory_settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<div class="flex-container alignitemscenter margin0">
<b data-i18n="ext_sum_title">Summarize</b>
<i id="summaryExtensionPopoutButton" class="fa-solid fa-window-restore menu_button margin0"></i>
</div>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<div id="summaryExtensionDrawerContents">
<label for="summary_source" data-i18n="ext_sum_with">Summarize with:</label>
<select id="summary_source" class="text_pole">
<option value="main" data-i18n="ext_sum_main_api">Main API</option>
<option value="extras">Extras API (deprecated)</option>
<option value="webllm" data-i18n="ext_sum_webllm">WebLLM Extension</option>
</select><br>
<div class="flex-container justifyspacebetween alignitemscenter">
<span data-i18n="ext_sum_current_summary">Current summary:</span>
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="memory_contents" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
<span class="flex1">&nbsp;</span>
<div id="memory_restore" class="menu_button margin0" data-i18n="[title]ext_sum_restore_tip" title="Restore a previous summary; use repeatedly to clear summarization state for this chat.">
<small data-i18n="ext_sum_restore_previous">Restore Previous</small>
</div>
</div>
<textarea id="memory_contents" class="text_pole textarea_compact" rows="6" data-i18n="[placeholder]ext_sum_memory_placeholder" placeholder="Summary will be generated here..."></textarea>
<div class="memory_contents_controls">
<div id="memory_force_summarize" data-summary-source="main,webllm" class="menu_button menu_button_icon" title="Trigger a summary update right now." data-i18n="[title]ext_sum_force_tip">
<i class="fa-solid fa-database"></i>
<span data-i18n="ext_sum_force_text">Summarize now</span>
</div>
<label for="memory_frozen" title="Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API)." data-i18n="[title]Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API)."><input id="memory_frozen" type="checkbox" /><span data-i18n="ext_sum_pause">Pause</span></label>
<label data-summary-source="main" for="memory_skipWIAN" title="Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN." data-i18n="[title]Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN.">
<input id="memory_skipWIAN" type="checkbox" />
<span data-i18n="ext_sum_no_wi_an">No WI/AN</span>
</label>
</div>
<div class="memory_contents_controls">
<div id="summarySettingsBlockToggle" class="menu_button menu_button_icon" data-i18n="[title]ext_sum_settings_tip" title="Edit summarization prompt, insertion position, etc.">
<i class="fa-solid fa-cog"></i>
<span data-i18n="ext_sum_settings">Summary Settings</span>
</div>
</div>
<div id="summarySettingsBlock" style="display:none;">
<div data-summary-source="main">
<label data-i18n="ext_sum_prompt_builder">
Prompt builder
</label>
<label class="checkbox_label" for="memory_prompt_builder_raw_blocking" data-i18n="[title]ext_sum_prompt_builder_1_desc" title="Extension will build its own prompt using messages that were not summarized yet. Blocks the chat until the summary is generated.">
<input id="memory_prompt_builder_raw_blocking" type="radio" name="memory_prompt_builder" value="1" />
<span data-i18n="ext_sum_prompt_builder_1">Raw, blocking</span>
</label>
<label class="checkbox_label" for="memory_prompt_builder_raw_non_blocking" data-i18n="[title]ext_sum_prompt_builder_2_desc" title="Extension will build its own prompt using messages that were not summarized yet. Does not block the chat while the summary is being generated. Not all backends support this mode.">
<input id="memory_prompt_builder_raw_non_blocking" type="radio" name="memory_prompt_builder" value="2" />
<span data-i18n="ext_sum_prompt_builder_2">Raw, non-blocking</span>
</label>
<label class="checkbox_label" id="memory_prompt_builder_default" data-i18n="[title]ext_sum_prompt_builder_3_desc" title="Extension will use the regular main prompt builder and add the summary request to it as the last system message.">
<input id="memory_prompt_builder_default" type="radio" name="memory_prompt_builder" value="0" />
<span data-i18n="ext_sum_prompt_builder_3">Classic, blocking</span>
</label>
</div>
<div data-summary-source="main,webllm">
<label for="memory_prompt" class="title_restorable">
<span data-i18n="Summary Prompt">Summary Prompt</span>
<div id="memory_prompt_restore" data-i18n="[title]ext_sum_restore_default_prompt_tip" title="Restore default prompt" class="right_menu_button">
<div class="fa-solid fa-clock-rotate-left"></div>
</div>
</label>
<textarea id="memory_prompt" class="text_pole textarea_compact" rows="6" data-i18n="[placeholder]ext_sum_prompt_placeholder" placeholder="This prompt will be sent to AI to request the summary generation. &lcub;&lcub;words&rcub;&rcub; will resolve to the 'Number of words' parameter."></textarea>
<label for="memory_prompt_words"><span data-i18n="ext_sum_target_length_1">Target summary length</span> <span data-i18n="ext_sum_target_length_2">(</span><span id="memory_prompt_words_value"></span><span data-i18n="ext_sum_target_length_3"> words)</span></label>
<input id="memory_prompt_words" type="range" value="{{defaultSettings.promptWords}}" min="{{defaultSettings.promptMinWords}}" max="{{defaultSettings.promptMaxWords}}" step="{{defaultSettings.promptWordsStep}}" />
<label for="memory_override_response_length">
<span data-i18n="ext_sum_api_response_length_1">API response length</span> <span data-i18n="ext_sum_api_response_length_2">(</span><span id="memory_override_response_length_value"></span><span data-i18n="ext_sum_api_response_length_3"> tokens)</span>
<small class="memory_disabled_hint" data-i18n="ext_sum_0_default">0 = default</small>
</label>
<input id="memory_override_response_length" type="range" value="{{defaultSettings.overrideResponseLength}}" min="{{defaultSettings.overrideResponseLengthMin}}" max="{{defaultSettings.overrideResponseLengthMax}}" step="{{defaultSettings.overrideResponseLengthStep}}" />
<label for="memory_max_messages_per_request">
<span data-i18n="ext_sum_raw_max_msg">[Raw/WebLLM] Max messages per request</span> (<span id="memory_max_messages_per_request_value"></span>)
<small class="memory_disabled_hint" data-i18n="ext_sum_0_unlimited">0 = unlimited</small>
</label>
<input id="memory_max_messages_per_request" type="range" value="{{defaultSettings.maxMessagesPerRequest}}" min="{{defaultSettings.maxMessagesPerRequestMin}}" max="{{defaultSettings.maxMessagesPerRequestMax}}" step="{{defaultSettings.maxMessagesPerRequestStep}}" />
<h4 data-i18n="Update frequency" class="textAlignCenter">
Update frequency
</h4>
<label for="memory_prompt_interval" class="title_restorable">
<span>
<span data-i18n="ext_sum_update_every_messages_1">Update every</span> <span id="memory_prompt_interval_value"></span><span data-i18n="ext_sum_update_every_messages_2"> messages</span>
<small class="memory_disabled_hint" data-i18n="ext_sum_0_disable">0 = disable</small>
</span>
<div id="memory_prompt_interval_auto" data-i18n="[title]ext_sum_auto_adjust_desc" title="Try to automatically adjust the interval based on the chat metrics." class="right_menu_button">
<div class="fa-solid fa-wand-magic-sparkles"></div>
</div>
</label>
<input id="memory_prompt_interval" type="range" value="{{defaultSettings.promptInterval}}" min="{{defaultSettings.promptMinInterval}}" max="{{defaultSettings.promptMaxInterval}}" step="{{defaultSettings.promptIntervalStep}}" />
<label for="memory_prompt_words_force" class="title_restorable">
<span>
<span data-i18n="ext_sum_update_every_words_1">Update every</span> <span id="memory_prompt_words_force_value"></span><span data-i18n="ext_sum_update_every_words_2"> words</span>
<small class="memory_disabled_hint" data-i18n="ext_sum_0_disable">0 = disable</small>
</span>
<div id="memory_prompt_words_auto" data-i18n="[title]ext_sum_auto_adjust_desc" title="Try to automatically adjust the interval based on the chat metrics." class="right_menu_button">
<div class="fa-solid fa-wand-magic-sparkles"></div>
</div>
</label>
<input id="memory_prompt_words_force" type="range" value="{{defaultSettings.promptForceWords}}" min="{{defaultSettings.promptMinForceWords}}" max="{{defaultSettings.promptMaxForceWords}}" step="{{defaultSettings.promptForceWordsStep}}" />
<small data-i18n="ext_sum_both_sliders">If both sliders are non-zero, then both will trigger summary updates at their respective intervals.</small>
<hr>
</div>
<div class="memory_template">
<label for="memory_template" data-i18n="ext_sum_injection_template">Injection Template</label>
<textarea id="memory_template" class="text_pole textarea_compact" rows="2" data-i18n="[placeholder]ext_sum_memory_template_placeholder" placeholder="&lcub;&lcub;summary&rcub;&rcub; will resolve to the current summary contents."></textarea>
</div>
<label for="memory_position" data-i18n="ext_sum_injection_position">Injection Position</label>
<label class="checkbox_label" for="memory_include_wi_scan" data-i18n="[title]ext_sum_include_wi_scan_desc" title="Include the latest summary in the WI scan.">
<input id="memory_include_wi_scan" type="checkbox" />
<span data-i18n="ext_sum_include_wi_scan">Include in World Info Scanning</span>
</label>
<div class="radio_group">
<label>
<input type="radio" name="memory_position" value="-1" />
<span data-i18n="None (not injected)">None (not injected)</span>
<i class="fa-solid fa-info-circle" title="The summary will not be injected into the prompt. You can still access it via the &lcub;&lcub;summary&rcub;&rcub; macro." data-i18n="[title]ext_sum_injection_position_none"></i>
</label>
<label>
<input type="radio" name="memory_position" value="2" />
<span data-i18n="Before Main Prompt / Story String">Before Main Prompt / Story String</span>
</label>
<label>
<input type="radio" name="memory_position" value="0" />
<span data-i18n="After Main Prompt / Story String">After Main Prompt / Story String</span>
</label>
<label class="flex-container alignItemsCenter" title="How many messages before the current end of the chat." data-i18n="[title]How many messages before the current end of the chat.">
<input type="radio" name="memory_position" value="1" />
<span data-i18n="In-chat @ Depth">In-chat @ Depth</span> <input id="memory_depth" class="text_pole widthUnset" type="number" min="0" max="9999" />
<span data-i18n="as">as</span>
<select id="memory_role" class="text_pole widthNatural">
<option value="0" data-i18n="System">System</option>
<option value="1" data-i18n="User">User</option>
<option value="2" data-i18n="Assistant">Assistant</option>
</select>
</label>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,47 @@
#memory_settings {
display: flex;
flex-direction: column;
}
#memory_contents {
field-sizing: content;
max-height: 50dvh;
}
#memory_restore {
width: max-content;
}
label[for="memory_frozen"],
label[for="memory_skipWIAN"] {
display: flex;
align-items: center;
margin: 0 !important;
}
label[for="memory_frozen"] input {
margin-right: 10px;
}
.memory_contents_controls {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.memory_disabled_hint {
margin-left: 2px;
}
#summarySettingsBlock {
display: flex;
flex-direction: column;
row-gap: 5px;
}
#summaryExtensionPopout {
display: flex;
flex-direction: column;
padding-top: 25px;
}

View File

@@ -0,0 +1,511 @@
import { QuickReply } from '../src/QuickReply.js';
import { QuickReplyContextLink } from '../src/QuickReplyContextLink.js';
import { QuickReplySet } from '../src/QuickReplySet.js';
import { QuickReplySettings } from '../src/QuickReplySettings.js';
import { SettingsUi } from '../src/ui/SettingsUi.js';
import { onlyUnique } from '../../../utils.js';
export class QuickReplyApi {
/** @type {QuickReplySettings} */ settings;
/** @type {SettingsUi} */ settingsUi;
constructor(/** @type {QuickReplySettings} */settings, /** @type {SettingsUi} */settingsUi) {
this.settings = settings;
this.settingsUi = settingsUi;
}
/**
* @param {QuickReply} qr
* @returns {QuickReplySet}
*/
getSetByQr(qr) {
return QuickReplySet.list.find(it=>it.qrList.includes(qr));
}
/**
* Finds and returns an existing Quick Reply Set by its name.
*
* @param {string} name name of the quick reply set
* @returns the quick reply set, or undefined if not found
*/
getSetByName(name) {
return QuickReplySet.get(name);
}
/**
* Finds and returns an existing Quick Reply by its set's name and its label.
*
* @param {string} setName name of the quick reply set
* @param {string|number} label label or numeric ID of the quick reply
* @returns the quick reply, or undefined if not found
*/
getQrByLabel(setName, label) {
const set = this.getSetByName(setName);
if (!set) return;
if (Number.isInteger(label)) return set.qrList.find(it=>it.id == label);
return set.qrList.find(it=>it.label == label);
}
/**
* Executes a quick reply by its index and returns the result.
*
* @param {Number} idx the index (zero-based) of the quick reply to execute
* @returns the return value of the quick reply, or undefined if not found
*/
async executeQuickReplyByIndex(idx) {
const qr = [...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? [])]
.map(it=>it.set.qrList)
.flat()[idx]
;
if (qr) {
return await qr.onExecute();
} else {
throw new Error(`No quick reply at index "${idx}"`);
}
}
/**
* Executes an existing quick reply.
*
* @param {string} setName name of the existing quick reply set
* @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID
* @param {object} [args] optional arguments
* @param {import('../../../slash-commands.js').ExecuteSlashCommandsOptions} [options] optional execution options
*/
async executeQuickReply(setName, label, args = {}, options = {}) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
return await qr.execute(args, false, false, options);
}
/**
* Adds or removes a quick reply set to the list of globally active quick reply sets.
*
* @param {string} name the name of the set
* @param {boolean} isVisible whether to show the set's buttons or not
*/
toggleGlobalSet(name, isVisible = true) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
if (this.settings.config.hasSet(set)) {
this.settings.config.removeSet(set);
} else {
this.settings.config.addSet(set, isVisible);
}
}
/**
* Adds a quick reply set to the list of globally active quick reply sets.
*
* @param {string} name the name of the set
* @param {boolean} isVisible whether to show the set's buttons or not
*/
addGlobalSet(name, isVisible = true) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
this.settings.config.addSet(set, isVisible);
}
/**
* Removes a quick reply set from the list of globally active quick reply sets.
*
* @param {string} name the name of the set
*/
removeGlobalSet(name) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
this.settings.config.removeSet(set);
}
/**
* Adds or removes a quick reply set to the list of the current chat's active quick reply sets.
*
* @param {string} name the name of the set
* @param {boolean} isVisible whether to show the set's buttons or not
*/
toggleChatSet(name, isVisible = true) {
if (!this.settings.chatConfig) return;
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
if (this.settings.chatConfig.hasSet(set)) {
this.settings.chatConfig.removeSet(set);
} else {
this.settings.chatConfig.addSet(set, isVisible);
}
}
/**
* Adds a quick reply set to the list of the current chat's active quick reply sets.
*
* @param {string} name the name of the set
* @param {boolean} isVisible whether to show the set's buttons or not
*/
addChatSet(name, isVisible = true) {
if (!this.settings.chatConfig) return;
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
this.settings.chatConfig.addSet(set, isVisible);
}
/**
* Removes a quick reply set from the list of the current chat's active quick reply sets.
*
* @param {string} name the name of the set
*/
removeChatSet(name) {
if (!this.settings.chatConfig) return;
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
this.settings.chatConfig.removeSet(set);
}
/**
* Creates a new quick reply in an existing quick reply set.
*
* @param {string} setName name of the quick reply set to insert the new quick reply into
* @param {string} label label for the new quick reply (text on the button)
* @param {object} [props]
* @param {string} [props.icon] the icon to show on the QR button
* @param {boolean} [props.showLabel] whether to show the label even when an icon is assigned
* @param {string} [props.message] the message to be sent or slash command to be executed by the new quick reply
* @param {string} [props.title] the title / tooltip to be shown on the quick reply button
* @param {boolean} [props.isHidden] whether to hide or show the button
* @param {boolean} [props.executeOnStartup] whether to execute the quick reply when SillyTavern starts
* @param {boolean} [props.executeOnUser] whether to execute the quick reply after a user has sent a message
* @param {boolean} [props.executeOnAi] whether to execute the quick reply after the AI has sent a message
* @param {boolean} [props.executeOnChatChange] whether to execute the quick reply when a new chat is loaded
* @param {boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected
* @param {boolean} [props.executeOnNewChat] whether to execute the quick reply when a new chat is created
* @param {boolean} [props.executeBeforeGeneration] whether to execute the quick reply before message generation
* @param {string} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated
* @returns {QuickReply} the new quick reply
*/
createQuickReply(setName, label, {
icon,
showLabel,
message,
title,
isHidden,
executeOnStartup,
executeOnUser,
executeOnAi,
executeOnChatChange,
executeOnGroupMemberDraft,
executeOnNewChat,
executeBeforeGeneration,
automationId,
} = {}) {
const set = this.getSetByName(setName);
if (!set) {
throw new Error(`No quick reply set with named "${setName}" found.`);
}
const qr = set.addQuickReply();
qr.label = label ?? '';
qr.icon = icon ?? '';
qr.showLabel = showLabel ?? false;
qr.message = message ?? '';
qr.title = title ?? '';
qr.isHidden = isHidden ?? false;
qr.executeOnStartup = executeOnStartup ?? false;
qr.executeOnUser = executeOnUser ?? false;
qr.executeOnAi = executeOnAi ?? false;
qr.executeOnChatChange = executeOnChatChange ?? false;
qr.executeOnGroupMemberDraft = executeOnGroupMemberDraft ?? false;
qr.executeOnNewChat = executeOnNewChat ?? false;
qr.executeBeforeGeneration = executeBeforeGeneration ?? false;
qr.automationId = automationId ?? '';
qr.onUpdate();
return qr;
}
/**
* Updates an existing quick reply.
*
* @param {string} setName name of the existing quick reply set
* @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID
* @param {object} [props]
* @param {string} [props.icon] the icon to show on the QR button
* @param {boolean} [props.showLabel] whether to show the label even when an icon is assigned
* @param {string} [props.newLabel] new label for quick reply (text on the button)
* @param {string} [props.message] the message to be sent or slash command to be executed by the quick reply
* @param {string} [props.title] the title / tooltip to be shown on the quick reply button
* @param {boolean} [props.isHidden] whether to hide or show the button
* @param {boolean} [props.executeOnStartup] whether to execute the quick reply when SillyTavern starts
* @param {boolean} [props.executeOnUser] whether to execute the quick reply after a user has sent a message
* @param {boolean} [props.executeOnAi] whether to execute the quick reply after the AI has sent a message
* @param {boolean} [props.executeOnChatChange] whether to execute the quick reply when a new chat is loaded
* @param {boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected
* @param {boolean} [props.executeOnNewChat] whether to execute the quick reply when a new chat is created
* @param {boolean} [props.executeBeforeGeneration] whether to execute the quick reply before message generation
* @param {string} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated
* @returns {QuickReply} the altered quick reply
*/
updateQuickReply(setName, label, {
icon,
showLabel,
newLabel,
message,
title,
isHidden,
executeOnStartup,
executeOnUser,
executeOnAi,
executeOnChatChange,
executeOnGroupMemberDraft,
executeOnNewChat,
executeBeforeGeneration,
automationId,
} = {}) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
qr.updateIcon(icon ?? qr.icon);
qr.updateShowLabel(showLabel ?? qr.showLabel);
qr.updateLabel(newLabel ?? qr.label);
qr.updateMessage(message ?? qr.message);
qr.updateTitle(title ?? qr.title);
qr.isHidden = isHidden ?? qr.isHidden;
qr.executeOnStartup = executeOnStartup ?? qr.executeOnStartup;
qr.executeOnUser = executeOnUser ?? qr.executeOnUser;
qr.executeOnAi = executeOnAi ?? qr.executeOnAi;
qr.executeOnChatChange = executeOnChatChange ?? qr.executeOnChatChange;
qr.executeOnGroupMemberDraft = executeOnGroupMemberDraft ?? qr.executeOnGroupMemberDraft;
qr.executeOnNewChat = executeOnNewChat ?? qr.executeOnNewChat;
qr.executeBeforeGeneration = executeBeforeGeneration ?? qr.executeBeforeGeneration;
qr.automationId = automationId ?? qr.automationId;
qr.onUpdate();
return qr;
}
/**
* Deletes an existing quick reply.
*
* @param {string} setName name of the existing quick reply set
* @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID
*/
deleteQuickReply(setName, label) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
qr.delete();
}
/**
* Adds an existing quick reply set as a context menu to an existing quick reply.
*
* @param {string} setName name of the existing quick reply set containing the quick reply
* @param {string|number} label label of the existing quick reply or its numeric ID
* @param {string} contextSetName name of the existing quick reply set to be used as a context menu
* @param {boolean} isChained whether or not to chain the context menu quick replies
*/
createContextItem(setName, label, contextSetName, isChained = false) {
const qr = this.getQrByLabel(setName, label);
const set = this.getSetByName(contextSetName);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
if (!set) {
throw new Error(`No quick reply set with name "${contextSetName}" found.`);
}
const cl = new QuickReplyContextLink();
cl.set = set;
cl.isChained = isChained;
qr.addContextLink(cl);
}
/**
* Removes a quick reply set from a quick reply's context menu.
*
* @param {string} setName name of the existing quick reply set containing the quick reply
* @param {string|number} label label of the existing quick reply or its numeric ID
* @param {string} contextSetName name of the existing quick reply set to be used as a context menu
*/
deleteContextItem(setName, label, contextSetName) {
const qr = this.getQrByLabel(setName, label);
const set = this.getSetByName(contextSetName);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
if (!set) {
throw new Error(`No quick reply set with name "${contextSetName}" found.`);
}
qr.removeContextLink(set.name);
}
/**
* Removes all entries from a quick reply's context menu.
*
* @param {string} setName name of the existing quick reply set containing the quick reply
* @param {string|number} label label of the existing quick reply or its numeric ID
*/
clearContextMenu(setName, label) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
qr.clearContextLinks();
}
/**
* Create a new quick reply set.
*
* @param {string} name name of the new quick reply set
* @param {object} [props]
* @param {boolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
* @param {boolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
* @param {boolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
* @returns {Promise<QuickReplySet>} the new quick reply set
*/
async createSet(name, {
disableSend,
placeBeforeInput,
injectInput,
} = {}) {
const set = new QuickReplySet();
set.name = name;
set.disableSend = disableSend ?? false;
set.placeBeforeInput = placeBeforeInput ?? false;
set.injectInput = injectInput ?? false;
const oldSet = this.getSetByName(name);
if (oldSet) {
QuickReplySet.list.splice(QuickReplySet.list.indexOf(oldSet), 1, set);
} else {
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(name) == 1);
if (idx > -1) {
QuickReplySet.list.splice(idx, 0, set);
} else {
QuickReplySet.list.push(set);
}
}
await set.save();
this.settingsUi.rerender();
return set;
}
/**
* Update an existing quick reply set.
*
* @param {string} name name of the existing quick reply set
* @param {object} [props]
* @param {boolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
* @param {boolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
* @param {boolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
* @returns {Promise<QuickReplySet>} the altered quick reply set
*/
async updateSet(name, {
disableSend,
placeBeforeInput,
injectInput,
} = {}) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
set.disableSend = disableSend ?? false;
set.placeBeforeInput = placeBeforeInput ?? false;
set.injectInput = injectInput ?? false;
await set.save();
this.settingsUi.rerender();
return set;
}
/**
* Delete an existing quick reply set.
*
* @param {string} name name of the existing quick reply set
*/
async deleteSet(name) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
await set.delete();
this.settingsUi.rerender();
}
/**
* Gets a list of all quick reply sets.
*
* @returns array with the names of all quick reply sets
*/
listSets() {
return QuickReplySet.list.map(it=>it.name);
}
/**
* Gets a list of all globally active quick reply sets.
*
* @returns array with the names of all quick reply sets
*/
listGlobalSets() {
return this.settings.config.setList.map(it=>it.set.name);
}
/**
* Gets a list of all quick reply sets activated by the current chat.
*
* @returns array with the names of all quick reply sets
*/
listChatSets() {
return this.settings.chatConfig?.setList?.flatMap(it=>it.set.name) ?? [];
}
/**
* Gets a list of all quick replies in the quick reply set.
*
* @param {string} setName name of the existing quick reply set
* @returns array with the labels of this set's quick replies
*/
listQuickReplies(setName) {
const set = this.getSetByName(setName);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
return set.qrList.map(it=>it.label);
}
/**
* Gets a list of all Automation IDs used by quick replies.
*
* @returns {String[]} array with all automation IDs used by quick replies
*/
listAutomationIds() {
return this
.listSets()
.flatMap(it => ({ set: it, qrs: this.listQuickReplies(it) }))
.map(it => it.qrs?.map(qr => this.getQrByLabel(it.set, qr)?.automationId))
.flat()
.filter(Boolean)
.filter(onlyUnique)
.map(String);
}
}

View File

@@ -0,0 +1,161 @@
<div id="qr--modalEditor">
<div id="qr--main">
<h3 data-i18n="Labels and Message">Labels and Message</h3>
<div class="qr--labels">
<label class="qr--fit">
<span class="qr--labelText" data-i18n="Label">Icon</span>
<small class="qr--labelHint">&nbsp;</small>
<div class="menu_button fa-fw" id="qr--modal-icon" title="Click to change icon"></div>
</label>
<div class="label">
<span class="qr--labelText" data-i18n="Label">Label</span>
<small class="qr--labelHint" data-i18n="(label of the button, if no icon is chosen) ">(label of the button, if no icon is chosen)</small>
<div class="qr--inputGroup">
<label class="checkbox_label" title="Show label even if an icon is assigned">
<input type="checkbox" id="qr--modal-showLabel">
Show
</label>
<input type="text" class="text_pole" id="qr--modal-label">
<div class="menu_button fa-fw fa-solid fa-chevron-down" id="qr--modal-switcher" title="Switch to another QR"></div>
</div>
</div>
<label>
<span class="qr--labelText" data-i18n="Title">Title</span>
<small class="qr--labelHint" data-i18n="(tooltip, leave empty to show message or /command)">(tooltip, leave empty to show message or /command)</small>
<input type="text" class="text_pole" id="qr--modal-title">
</label>
</div>
<div class="qr--modal-messageContainer">
<label for="qr--modal-message" data-i18n="Message / Command:">
Message / Command:
</label>
<div class="qr--modal-editorSettings">
<label class="checkbox_label">
<input type="checkbox" id="qr--modal-wrap">
<span data-i18n="Word wrap">Word wrap</span>
</label>
<label class="checkbox_label">
<span data-i18n="Tab size:">Tab size:</span>
<input type="number" min="1" max="9" id="qr--modal-tabSize" class="text_pole">
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--modal-executeShortcut">
<span data-i18n="Ctrl+Enter to execute">Ctrl+Enter to execute</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--modal-syntax">
<span>Syntax highlight</span>
</label>
<small>Ctrl+Alt+Click (or F9) to set / remove breakpoints</small>
<small>Ctrl+<span id="qr--modal-commentKey"></span> to toggle block comments</small>
</div>
<div id="qr--modal-messageHolder">
<pre id="qr--modal-messageSyntax"><code id="qr--modal-messageSyntaxInner" class="hljs language-stscript"></code></pre>
<textarea id="qr--modal-message" spellcheck="false"></textarea>
</div>
</div>
</div>
<div id="qr--resizeHandle"></div>
<div id="qr--qrOptions">
<h3 data-i18n="Context Menu">Context Menu</h3>
<div id="qr--ctxEditor">
<template id="qr--ctxItem">
<div class="qr--ctxItem" data-order="0">
<div class="drag-handle ui-sortable-handle"></div>
<select class="qr--set"></select>
<label class="qr--isChainedLabel checkbox_label" title="When enabled, the current Quick Reply will be sent together with (before) the clicked QR from the context menu.">
<span data-i18n="Chaining:">Chaining:</span>
<input type="checkbox" class="qr--isChained">
</label>
<div class="qr--delete menu_button menu_button_icon fa-solid fa-trash-can" title="Remove entry"></div>
</div>
</template>
</div>
<div class="qr--ctxEditorActions">
<span id="qr--ctxAdd" class="menu_button menu_button_icon fa-solid fa-plus" title="Add quick reply set to context menu"></span>
</div>
<h3 data-i18n="Auto-Execute">Auto-Execute</h3>
<div id="qr--autoExec" class="flex-container flexFlowColumn">
<label class="checkbox_label" title="Prevent this quick reply from triggering other auto-executed quick replies while auto-executing (i.e., prevent recursive auto-execution)">
<input type="checkbox" id="qr--preventAutoExecute" >
<span><i class="fa-solid fa-fw fa-plane-slash"></i><span data-i18n="Don't trigger auto-execute">Don't trigger auto-execute</span></span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--isHidden" >
<span><i class="fa-solid fa-fw fa-eye-slash"></i><span data-i18n="Invisible (auto-execute only)">Invisible (auto-execute only)</span></span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnStartup" >
<span><i class="fa-solid fa-fw fa-rocket"></i><span data-i18n="Execute on startup">Execute on startup</span></span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnUser" >
<span><i class="fa-solid fa-fw fa-user"></i><span data-i18n="Execute on user message">Execute on user message</span></span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnAi" >
<span><i class="fa-solid fa-fw fa-robot"></i><span data-i18n="Execute on AI message">Execute on AI message</span></span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnChatChange" >
<span><i class="fa-solid fa-fw fa-message"></i><span data-i18n="Execute on chat change">Execute on chat change</span></span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnNewChat">
<span><i class="fa-solid fa-fw fa-comments"></i><span data-i18n="Execute on new chat">Execute on new chat</span></span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeOnGroupMemberDraft">
<span><i class="fa-solid fa-fw fa-people-group"></i><span data-i18n="Execute on group member draft">Execute on group member draft</span></span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="qr--executeBeforeGeneration" >
<span><i class="fa-solid fa-fw fa-paper-plane"></i><span data-i18n="Execute before message generation">Execute before message generation</span></span>
</label>
<div class="flex-container alignItemsBaseline flexFlowColumn flexNoGap" title="Activate this quick reply when a World Info entry with the same Automation ID is triggered.">
<small data-i18n="Automation ID:">Automation ID</small>
<input type="text" id="qr--automationId" class="text_pole flex1" placeholder="( None )">
</div>
</div>
<h3 data-i18n="Testing">Testing</h3>
<div id="qr--modal-executeButtons">
<div id="qr--modal-execute" class="qr--modal-executeButton menu_button" title="Execute the quick reply now">
<i class="fa-solid fa-play"></i>
<span data-i18n="Execute">Execute</span>
</div>
<div id="qr--modal-pause" class="qr--modal-executeButton menu_button" title="Pause / continue execution">
<span class="qr--modal-executeComboIcon">
<i class="fa-solid fa-play"></i>
<i class="fa-solid fa-pause"></i>
</span>
</div>
<div id="qr--modal-stop" class="qr--modal-executeButton menu_button" title="Abort execution">
<i class="fa-solid fa-stop"></i>
</div>
</div>
<div id="qr--modal-executeProgress"></div>
<div id="qr--modal-executeErrors"></div>
<div id="qr--modal-executeResult"></div>
<div id="qr--modal-debugButtons">
<div title="Resume" id="qr--modal-resume" class="qr--modal-debugButton menu_button"></div>
<div title="Step Over" id="qr--modal-step" class="qr--modal-debugButton menu_button"></div>
<div title="Step Into" id="qr--modal-stepInto" class="qr--modal-debugButton menu_button"></div>
<div title="Step Out" id="qr--modal-stepOut" class="qr--modal-debugButton menu_button"></div>
<div title="Minimize" id="qr--modal-minimize" class="qr--modal-debugButton menu_button fa-solid fa-minimize"></div>
<div title="Maximize" id="qr--modal-maximize" class="qr--modal-debugButton menu_button fa-solid fa-maximize"></div>
</div>
<textarea rows="1" id="qr--modal-send_textarea" placeholder="Chat input for use with {{input}}" title="Chat input for use with {{input}}"></textarea>
<div id="qr--modal-debugState"></div>
</div>
</div>

View File

@@ -0,0 +1,99 @@
<div id="qr--settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<strong data-i18n="Quick Reply">Quick Reply</strong>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<label class="flex-container">
<input type="checkbox" id="qr--isEnabled"><span data-i18n="Enable Quick Replies">Enable Quick Replies</span>
</label>
<label class="flex-container">
<input type="checkbox" id="qr--isCombined"><span data-i18n="Combine Quick Replies">Combine Quick Replies</span>
</label>
<label class="flex-container">
<input type="checkbox" id="qr--showPopoutButton"><span data-i18n="Show Popout Button">Show Popout Button (on Desktop)</span>
</label>
<hr>
<div id="qr--global">
<div class="qr--head">
<div class="qr--title" data-i18n="Global Quick Reply Sets">Global Quick Reply Sets</div>
<div class="qr--actions">
<div class="qr--setListAdd menu_button menu_button_icon fa-solid fa-plus" id="qr--global-setListAdd" title="Add quick reply set"></div>
</div>
</div>
<div id="qr--global-setList" class="qr--setList"></div>
</div>
<hr>
<div id="qr--chat">
<div class="qr--head">
<div class="qr--title" data-i18n="Chat Quick Reply Sets">Chat Quick Reply Sets</div>
<div class="qr--actions">
<div class="qr--setListAdd menu_button menu_button_icon fa-solid fa-plus" id="qr--chat-setListAdd" title="Add quick reply set"></div>
</div>
</div>
<div id="qr--chat-setList" class="qr--setList"></div>
</div>
<hr>
<div id="qr--character">
<div class="qr--head">
<div class="qr--title" data-i18n="Character Quick Reply Sets">Character Quick Reply Sets</div>
<small data-i18n="(Private)">(Private)</small>
<div class="qr--actions">
<div class="qr--setListAdd menu_button menu_button_icon fa-solid fa-plus" id="qr--character-setListAdd" title="Add quick reply set"></div>
</div>
</div>
<div id="qr--character-setList" class="qr--setList"></div>
</div>
<hr>
<div id="qr--editor">
<div class="qr--head">
<div class="qr--title" data-i18n="Edit Quick Replies">Edit Quick Replies</div>
<div class="qr--actions">
<select id="qr--set" class="text_pole"></select>
<div class="qr--add menu_button menu_button_icon fa-solid fa-pencil" id="qr--set-rename" title="Rename quick reply set"></div>
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-new" title="Create new quick reply set"></div>
<div class="qr--add menu_button menu_button_icon fa-solid fa-file-import" id="qr--set-import" title="Import quick reply set"></div>
<input type="file" id="qr--set-importFile" accept=".json" hidden>
<div class="qr--add menu_button menu_button_icon fa-solid fa-file-export" id="qr--set-export" title="Export quick reply set"></div>
<div class="qr-add menu_button menu_button_icon fa-solid fa-paste" id="qr--set-duplicate" title="Duplicate quick reply set"></div>
<div class="qr--del menu_button menu_button_icon fa-solid fa-trash redWarningBG" id="qr--set-delete" title="Delete quick reply set"></div>
</div>
</div>
<div id="qr--set-settings">
<label class="flex-container">
<input type="checkbox" id="qr--disableSend"> <span data-i18n="Disable Send (Insert Into Input Field)">Disable send (insert into input field)</span>
</label>
<label class="flex-container">
<input type="checkbox" id="qr--placeBeforeInput"> <span data-i18n="Place Quick Reply Before Input">Place quick reply before input</span>
</label>
<label class="flex-container" id="qr--injectInputContainer">
<input type="checkbox" id="qr--injectInput"> <span><span data-i18n="Inject user input automatically">Inject user input automatically</span> <small><span data-i18n="(if disabled, use ">(if disabled, use</span><code>{{input}}</code> <span data-i18n="macro for manual injection)">macro for manual injection)</span></small></span>
</label>
<div class="flex-container alignItemsCenter">
<toolcool-color-picker id="qr--color"></toolcool-color-picker>
<div class="menu_button" id="qr--colorClear">Clear</div>
<span data-i18n="Color">Color</span>
</div>
<label class="flex-container" id="qr--onlyBorderColorContainer">
<input type="checkbox" id="qr--onlyBorderColor"> <span data-i18n="Only apply color as accent">Only apply color as accent</span>
</label>
</div>
<div id="qr--set-qrList" class="qr--qrList"></div>
<div class="qr--set-qrListActions">
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-add" title="Add quick reply"></div>
<div class="qr--paste menu_button menu_button_icon fa-solid fa-paste" id="qr--set-paste" title="Paste quick reply from clipboard"></div>
<div class="qr--import menu_button menu_button_icon fa-solid fa-file-import" id="qr--set-importQr" title="Import quick reply from file"></div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,324 @@
import { chat, chat_metadata, eventSource, event_types, getRequestHeaders, this_chid, characters } from '../../../script.js';
import { extension_settings } from '../../extensions.js';
import { QuickReplyApi } from './api/QuickReplyApi.js';
import { AutoExecuteHandler } from './src/AutoExecuteHandler.js';
import { QuickReply } from './src/QuickReply.js';
import { QuickReplyConfig } from './src/QuickReplyConfig.js';
import { QuickReplySet } from './src/QuickReplySet.js';
import { QuickReplySettings } from './src/QuickReplySettings.js';
import { SlashCommandHandler } from './src/SlashCommandHandler.js';
import { ButtonUi } from './src/ui/ButtonUi.js';
import { SettingsUi } from './src/ui/SettingsUi.js';
import { debounceAsync } from '../../utils.js';
import { selected_group } from '../../group-chats.js';
export { debounceAsync };
const _VERBOSE = true;
export const debug = (...msg) => _VERBOSE ? console.debug('[QR2]', ...msg) : null;
export const log = (...msg) => _VERBOSE ? console.log('[QR2]', ...msg) : null;
export const warn = (...msg) => _VERBOSE ? console.warn('[QR2]', ...msg) : null;
const defaultConfig = {
setList: [{
set: 'Default',
isVisible: true,
}],
};
const defaultSettings = {
isEnabled: false,
isCombined: false,
config: defaultConfig,
};
/** @type {Boolean}*/
let isReady = false;
/** @type {Function[]}*/
let executeQueue = [];
/** @type {string}*/
let lastCharId;
/** @type {QuickReplySettings}*/
let settings;
/** @type {SettingsUi} */
let manager;
/** @type {ButtonUi} */
let buttons;
/** @type {AutoExecuteHandler} */
let autoExec;
/** @type {QuickReplyApi} */
export let quickReplyApi;
const loadSets = async () => {
const response = await fetch('/api/settings/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
});
if (response.ok) {
const setList = (await response.json()).quickReplyPresets ?? [];
for (const set of setList) {
if (set.version !== 2) {
// migrate old QR set
set.version = 2;
set.disableSend = set.quickActionEnabled ?? false;
set.placeBeforeInput = set.placeBeforeInputEnabled ?? false;
set.injectInput = set.AutoInputInject ?? false;
set.qrList = set.quickReplySlots.map((slot,idx)=>{
const qr = {};
qr.id = idx + 1;
qr.label = slot.label ?? '';
qr.title = slot.title ?? '';
qr.message = slot.mes ?? '';
qr.isHidden = slot.hidden ?? false;
qr.executeOnStartup = slot.autoExecute_appStartup ?? false;
qr.executeOnUser = slot.autoExecute_userMessage ?? false;
qr.executeOnAi = slot.autoExecute_botMessage ?? false;
qr.executeOnChatChange = slot.autoExecute_chatLoad ?? false;
qr.executeOnGroupMemberDraft = slot.autoExecute_groupMemberDraft ?? false;
qr.executeOnNewChat = slot.autoExecute_newChat ?? false;
qr.executeBeforeGeneration = slot.autoExecute_beforeGeneration ?? false;
qr.automationId = slot.automationId ?? '';
qr.contextList = (slot.contextMenu ?? []).map(it=>({
set: it.preset,
isChained: it.chain,
}));
return qr;
});
}
if (set.version == 2) {
QuickReplySet.list.push(QuickReplySet.from(JSON.parse(JSON.stringify(set))));
}
}
// need to load QR lists after all sets are loaded to be able to resolve context menu entries
setList.forEach((set, idx)=>{
QuickReplySet.list[idx].qrList = set.qrList.map(it=>QuickReply.from(it));
QuickReplySet.list[idx].init();
});
log('sets: ', QuickReplySet.list);
}
};
const loadSettings = async () => {
if (!extension_settings.quickReplyV2) {
if (!extension_settings.quickReply) {
extension_settings.quickReplyV2 = defaultSettings;
} else {
extension_settings.quickReplyV2 = {
isEnabled: extension_settings.quickReply.quickReplyEnabled ?? false,
isCombined: false,
isPopout: false,
config: {
setList: [{
set: extension_settings.quickReply.selectedPreset ?? extension_settings.quickReply.name ?? 'Default',
isVisible: true,
}],
},
};
}
}
try {
settings = QuickReplySettings.from(extension_settings.quickReplyV2);
settings.config.scope = 'global';
settings.config.onUpdate = () => settings.save();
} catch (ex) {
settings = QuickReplySettings.from(defaultSettings);
}
};
const executeIfReadyElseQueue = async (functionToCall, args) => {
if (isReady) {
log('calling', { functionToCall, args });
await functionToCall(...args);
} else {
log('queueing', { functionToCall, args });
executeQueue.push(async()=>await functionToCall(...args));
}
};
const handleCharChange = () => {
if (lastCharId === this_chid) return;
// Unload the old character's config and update the character ID cache.
settings.charConfig = null;
lastCharId = this_chid;
// If no character is loaded, there's nothing more to do.
/** @type {Character} */
const character = characters[this_chid];
if (!character || selected_group) {
return;
}
// Get the character-specific config from the local settings storage.
let charConfig = settings.characterConfigs[character.avatar];
// If no config exists for this character, create a new one.
if (!charConfig) {
charConfig = QuickReplyConfig.from({ setList: [] });
settings.characterConfigs[character.avatar] = charConfig;
}
charConfig.scope = 'character';
// The main settings save function will handle persistence.
charConfig.onUpdate = () => settings.save();
settings.charConfig = charConfig;
};
const init = async () => {
await loadSets();
await loadSettings();
log('settings: ', settings);
manager = new SettingsUi(settings);
document.querySelector('#qr_container').append(await manager.render());
buttons = new ButtonUi(settings);
buttons.show();
settings.onSave = ()=>buttons.refresh();
window['executeQuickReplyByName'] = async(name, args = {}, options = {}) => {
let qr = [
...settings.config.setList,
...(settings.chatConfig?.setList ?? []),
...(settings.charConfig?.setList ?? []),
]
.map(it => it.set.qrList)
.flat()
.find(it=>it.label == name)
;
if (!qr) {
let [setName, ...qrName] = name.split('.');
qrName = qrName.join('.');
let qrs = QuickReplySet.get(setName);
if (qrs) {
qr = qrs.qrList.find(it=>it.label == qrName);
}
}
if (qr && qr.onExecute) {
return await qr.execute(args, false, true, options);
} else {
throw new Error(`No Quick Reply found for "${name}".`);
}
};
quickReplyApi = new QuickReplyApi(settings, manager);
const slash = new SlashCommandHandler(quickReplyApi);
slash.init();
autoExec = new AutoExecuteHandler(settings);
eventSource.on(event_types.APP_READY, async()=>await finalizeInit());
globalThis.quickReplyApi = quickReplyApi;
};
const finalizeInit = async () => {
debug('executing startup');
await autoExec.handleStartup();
debug('/executing startup');
debug(`executing queue (${executeQueue.length} items)`);
while (executeQueue.length > 0) {
const func = executeQueue.shift();
await func();
}
debug('/executing queue');
isReady = true;
debug('READY');
};
await init();
const purgeCharacterQuickReplySets = ({ character }) => {
// Remove the character's Quick Reply Sets from the settings.
const avatar = character?.avatar;
if (avatar && avatar in settings.characterConfigs) {
log(`Purging Quick Reply Sets for character: ${avatar}`);
delete settings.characterConfigs[avatar];
settings.save();
}
};
const updateCharacterQuickReplySets = (oldAvatar, newAvatar) => {
// Update the character's Quick Reply Sets in the settings.
if (oldAvatar && newAvatar && oldAvatar !== newAvatar) {
log(`Updating Quick Reply Sets for character: ${oldAvatar} -> ${newAvatar}`);
if (settings.characterConfigs[oldAvatar]) {
settings.characterConfigs[newAvatar] = settings.characterConfigs[oldAvatar];
delete settings.characterConfigs[oldAvatar];
settings.save();
}
}
};
const onChatChanged = async (chatIdx) => {
log('CHAT_CHANGED', chatIdx);
handleCharChange();
if (chatIdx) {
const chatConfig = QuickReplyConfig.from(chat_metadata.quickReply ?? {});
chatConfig.scope = 'chat';
chatConfig.onUpdate = () => settings.save();
settings.chatConfig = chatConfig;
} else {
settings.chatConfig = null;
}
manager.rerender();
buttons.refresh();
await autoExec.handleChatChanged();
};
eventSource.on(event_types.CHAT_CHANGED, (...args)=>executeIfReadyElseQueue(onChatChanged, args));
eventSource.on(event_types.CHARACTER_DELETED, purgeCharacterQuickReplySets);
eventSource.on(event_types.CHARACTER_RENAMED, updateCharacterQuickReplySets);
const onUserMessage = async () => {
await autoExec.handleUser();
};
eventSource.makeFirst(event_types.USER_MESSAGE_RENDERED, (...args)=>executeIfReadyElseQueue(onUserMessage, args));
const onAiMessage = async (messageId) => {
if (['...'].includes(chat[messageId]?.mes)) {
log('QR auto-execution suppressed for swiped message');
return;
}
await autoExec.handleAi();
};
eventSource.makeFirst(event_types.CHARACTER_MESSAGE_RENDERED, (...args)=>executeIfReadyElseQueue(onAiMessage, args));
const onGroupMemberDraft = async () => {
await autoExec.handleGroupMemberDraft();
};
eventSource.on(event_types.GROUP_MEMBER_DRAFTED, (...args) => executeIfReadyElseQueue(onGroupMemberDraft, args));
const onWIActivation = async (entries) => {
await autoExec.handleWIActivation(entries);
};
eventSource.on(event_types.WORLD_INFO_ACTIVATED, (...args) => executeIfReadyElseQueue(onWIActivation, args));
const onNewChat = async () => {
await autoExec.handleNewChat();
};
eventSource.on(event_types.CHAT_CREATED, (...args) => executeIfReadyElseQueue(onNewChat, args));
eventSource.on(event_types.GROUP_CHAT_CREATED, (...args) => executeIfReadyElseQueue(onNewChat, args));
const onBeforeGeneration = async (_generationType, _options = {}, isDryRun = false) => {
if (isDryRun) {
log('Before-generation hook skipped due to dryRun.');
return;
}
if (selected_group && this_chid === undefined) {
log('Before-generation hook skipped for event before group wrapper.');
return;
}
await autoExec.handleBeforeGeneration();
};
eventSource.on(event_types.GENERATION_AFTER_COMMANDS, (...args) => executeIfReadyElseQueue(onBeforeGeneration, args));

View File

@@ -0,0 +1,11 @@
{
"display_name": "Quick Replies",
"loading_order": 12,
"requires": [],
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "RossAscends#1779",
"version": "2.0.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}

View File

@@ -0,0 +1,108 @@
import { warn } from '../index.js';
import { QuickReply } from './QuickReply.js';
import { QuickReplySettings } from './QuickReplySettings.js';
export class AutoExecuteHandler {
/** @type {QuickReplySettings} */ settings;
/** @type {Boolean[]}*/ preventAutoExecuteStack = [];
constructor(/** @type {QuickReplySettings} */settings) {
this.settings = settings;
}
checkExecute() {
return this.settings.isEnabled && !this.preventAutoExecuteStack.slice(-1)[0];
}
async performAutoExecute(/** @type {QuickReply[]} */qrList) {
for (const qr of qrList) {
this.preventAutoExecuteStack.push(qr.preventAutoExecute);
try {
await qr.execute({ isAutoExecute:true });
} catch (ex) {
warn(ex);
} finally {
this.preventAutoExecuteStack.pop();
}
}
}
getCommands(eventName) {
const getFromConfig = (config) => {
// This safely handles cases where a link exists but the set hasn't been loaded (link.set is null)
return config?.setList?.map(link => link.set ? link.set.qrList.filter(qr => qr[eventName]) : [])?.flat() ?? [];
};
return [
...getFromConfig(this.settings.config),
...getFromConfig(this.settings.chatConfig),
...getFromConfig(this.settings.charConfig),
];
}
async handleStartup() {
if (!this.checkExecute()) return;
await this.performAutoExecute(this.getCommands('executeOnStartup'));
}
async handleUser() {
if (!this.checkExecute()) return;
await this.performAutoExecute(this.getCommands('executeOnUser'));
}
async handleAi() {
if (!this.checkExecute()) return;
await this.performAutoExecute(this.getCommands('executeOnAi'));
}
async handleChatChanged() {
if (!this.checkExecute()) return;
await this.performAutoExecute(this.getCommands('executeOnChatChange'));
}
async handleGroupMemberDraft() {
if (!this.checkExecute()) return;
await this.performAutoExecute(this.getCommands('executeOnGroupMemberDraft'));
}
async handleNewChat() {
if (!this.checkExecute()) return;
await this.performAutoExecute(this.getCommands('executeOnNewChat'));
}
async handleBeforeGeneration() {
if (!this.checkExecute()) return;
await this.performAutoExecute(this.getCommands('executeBeforeGeneration'));
}
/**
* @param {any[]} entries Set of activated entries
*/
async handleWIActivation(entries) {
if (!this.checkExecute() || !Array.isArray(entries) || entries.length === 0) return;
const automationIds = entries.map(entry => entry.automationId).filter(Boolean);
if (automationIds.length === 0) return;
const getFromConfig = (config) => {
return config?.setList
?.map(link => link.set ? link.set.qrList.filter(qr => qr.automationId && automationIds.includes(qr.automationId)) : [])
?.flat() ?? [];
};
const qrList = [
...getFromConfig(this.settings.config),
...getFromConfig(this.settings.chatConfig),
...getFromConfig(this.settings.charConfig),
];
await this.performAutoExecute(qrList);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,127 @@
import { getSortableDelay } from '../../../utils.js';
import { QuickReplySetLink } from './QuickReplySetLink.js';
import { QuickReplySet } from './QuickReplySet.js';
export class QuickReplyConfig {
/**@type {QuickReplySetLink[]}*/ setList = [];
/**@type {'global'|'chat'|'character'}*/ scope;
/**@type {Function}*/ onUpdate;
/**@type {Function}*/ onRequestEditSet;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ setListDom;
static from(props) {
props.setList = props.setList?.map(it=>QuickReplySetLink.from(it))?.filter(it=>it.set) ?? [];
const instance = Object.assign(new this(), props);
instance.init();
return instance;
}
init() {
this.setList.forEach(it=>this.hookQuickReplyLink(it));
}
hasSet(qrs) {
return this.setList.find(it=>it.set == qrs) != null;
}
addSet(qrs, isVisible = true) {
if (!this.hasSet(qrs)) {
const qrl = new QuickReplySetLink();
qrl.set = qrs;
qrl.isVisible = isVisible;
this.hookQuickReplyLink(qrl);
this.setList.push(qrl);
this.setListDom.append(qrl.renderSettings(this.setList.length - 1));
this.update();
}
}
removeSet(qrs) {
const idx = this.setList.findIndex(it=>it.set == qrs);
if (idx > -1) {
this.setList.splice(idx, 1);
this.update();
this.updateSetListDom();
}
}
renderSettingsInto(/**@type {HTMLElement}*/root) {
/**@type {HTMLElement}*/
this.setListDom = root.querySelector('.qr--setList');
root.querySelector('.qr--setListAdd').addEventListener('click', ()=>{
const newSet = QuickReplySet.list.find(qr=>!this.setList.find(qrl=>qrl.set == qr));
if (newSet) {
this.addSet(newSet);
} else {
toastr.warning('All existing QR Sets have already been added.');
}
});
this.updateSetListDom();
}
updateSetListDom() {
this.setListDom.innerHTML = '';
// @ts-ignore
$(this.setListDom).sortable({
delay: getSortableDelay(),
stop: ()=>this.onSetListSort(),
});
this.setList.filter(it=>!it.set.isDeleted).forEach((qrl,idx)=>this.setListDom.append(qrl.renderSettings(idx)));
}
onSetListSort() {
this.setList = Array.from(this.setListDom.children).map((it,idx)=>{
const qrl = this.setList[Number(it.getAttribute('data-order'))];
qrl.index = idx;
it.setAttribute('data-order', String(idx));
return qrl;
});
this.update();
}
/**
* @param {QuickReplySetLink} qrl
*/
hookQuickReplyLink(qrl) {
qrl.onDelete = ()=>this.deleteQuickReplyLink(qrl);
qrl.onUpdate = ()=>this.update();
qrl.onRequestEditSet = ()=>this.requestEditSet(qrl.set);
}
deleteQuickReplyLink(qrl) {
this.setList.splice(this.setList.indexOf(qrl), 1);
this.update();
}
update() {
if (this.onUpdate) {
this.onUpdate(this);
}
}
requestEditSet(qrs) {
if (this.onRequestEditSet) {
this.onRequestEditSet(qrs);
}
}
toJSON() {
return {
setList: this.setList,
};
}
}

View File

@@ -0,0 +1,22 @@
import { QuickReplySet } from './QuickReplySet.js';
export class QuickReplyContextLink {
static from(props) {
props.set = QuickReplySet.get(props.set);
const x = Object.assign(new this(), props);
return x;
}
/**@type {QuickReplySet}*/ set;
/**@type {Boolean}*/ isChained = false;
toJSON() {
return {
set: this.set?.name,
isChained: this.isChained,
};
}
}

View File

@@ -0,0 +1,412 @@
import { getRequestHeaders, substituteParams } from '../../../../script.js';
import { Popup, POPUP_RESULT, POPUP_TYPE } from '../../../popup.js';
import { executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions } from '../../../slash-commands.js';
import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
import { debounceAsync, warn } from '../index.js';
import { QuickReply } from './QuickReply.js';
export class QuickReplySet {
/**@type {QuickReplySet[]}*/ static list = [];
/**
* @param {Partial<QuickReplySet>} props
* @returns {QuickReplySet}
*/
static from(props) {
props.qrList = []; //props.qrList?.map(it=>QuickReply.from(it));
const instance = Object.assign(new this(), props);
// instance.init();
return instance;
}
/**
* @param {string} name - name of the QuickReplySet
*/
static get(name) {
return this.list.find(it=>it.name == name);
}
/**@type {string}*/ name;
/**@type {'global'|'chat'|'character'}*/ scope = 'global';
/**@type {boolean}*/ disableSend = false;
/**@type {boolean}*/ placeBeforeInput = false;
/**@type {boolean}*/ injectInput = false;
/**@type {string}*/ color = 'transparent';
/**@type {boolean}*/ onlyBorderColor = false;
/**@type {QuickReply[]}*/ qrList = [];
/**@type {number}*/ idIndex = 0;
/**@type {boolean}*/ isDeleted = false;
/**@type {function}*/ save;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ settingsDom;
constructor() {
this.save = debounceAsync(()=>this.performSave(), 200);
}
init() {
this.qrList.forEach(qr=>this.hookQuickReply(qr));
}
unrender() {
this.dom?.remove();
this.dom = null;
}
render() {
this.unrender();
if (!this.dom) {
const root = document.createElement('div'); {
this.dom = root;
root.classList.add('qr--buttons');
this.updateColor();
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
root.append(qr.render());
});
}
}
return this.dom;
}
rerender() {
if (!this.dom) return;
this.dom.innerHTML = '';
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
this.dom.append(qr.render());
});
}
updateColor() {
if (!this.dom) return;
if (this.color && this.color != 'transparent') {
this.dom.style.setProperty('--qr--color', this.color);
this.dom.classList.add('qr--color');
if (this.onlyBorderColor) {
this.dom.classList.add('qr--borderColor');
} else {
this.dom.classList.remove('qr--borderColor');
}
} else {
this.dom.style.setProperty('--qr--color', 'transparent');
this.dom.classList.remove('qr--color');
this.dom.classList.remove('qr--borderColor');
}
}
renderSettings() {
if (!this.settingsDom) {
this.settingsDom = document.createElement('div'); {
this.settingsDom.classList.add('qr--set-qrListContents');
this.qrList.forEach((qr,idx)=>{
this.renderSettingsItem(qr, idx);
});
}
}
return this.settingsDom;
}
/**
*
* @param {QuickReply} qr
* @param {number} idx
*/
renderSettingsItem(qr, idx) {
this.settingsDom.append(qr.renderSettings(idx));
}
/**
*
* @param {QuickReply} qr
*/
async debug(qr) {
const parser = new SlashCommandParser();
const closure = parser.parse(qr.message, true, [], qr.abortController, qr.debugController);
closure.source = `${this.name}.${qr.label}`;
closure.onProgress = (done, total) => qr.updateEditorProgress(done, total);
closure.scope.setMacro('arg::*', '');
return (await closure.execute())?.pipe;
}
/**
*
* @param {QuickReply} qr The QR to execute.
* @param {object} options
* @param {string} [options.message] (null) altered message to be used
* @param {boolean} [options.isAutoExecute] (false) whether the execution is triggered by auto execute
* @param {boolean} [options.isEditor] (false) whether the execution is triggered by the QR editor
* @param {boolean} [options.isRun] (false) whether the execution is triggered by /run or /: (window.executeQuickReplyByName)
* @param {SlashCommandScope} [options.scope] (null) scope to be used when running the command
* @param {import('../../../slash-commands.js').ExecuteSlashCommandsOptions} [options.executionOptions] ({}) further execution options
* @returns
*/
async executeWithOptions(qr, options = {}) {
options = Object.assign({
message:null,
isAutoExecute:false,
isEditor:false,
isRun:false,
scope:null,
executionOptions:{},
}, options);
const execOptions = options.executionOptions;
/**@type {HTMLTextAreaElement}*/
const ta = document.querySelector('#send_textarea');
const finalMessage = options.message ?? qr.message;
let input = ta.value;
if (!options.isAutoExecute && !options.isEditor && !options.isRun && this.injectInput && input.length > 0) {
if (this.placeBeforeInput) {
input = `${finalMessage} ${input}`;
} else {
input = `${input} ${finalMessage}`;
}
} else {
input = `${finalMessage} `;
}
if (input[0] == '/' && !this.disableSend) {
let result;
if (options.isAutoExecute || options.isRun) {
result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, {
handleParserErrors: true,
scope: options.scope,
source: `${this.name}.${qr.label}`,
}));
} else if (options.isEditor) {
result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, {
handleParserErrors: false,
scope: options.scope,
abortController: qr.abortController,
source: `${this.name}.${qr.label}`,
onProgress: (done, total) => qr.updateEditorProgress(done, total),
}));
} else {
result = await executeSlashCommandsOnChatInput(input, Object.assign(execOptions, {
scope: options.scope,
source: `${this.name}.${qr.label}`,
}));
}
return typeof result === 'object' ? result?.pipe : '';
}
ta.value = substituteParams(input);
ta.focus();
if (!this.disableSend) {
// @ts-ignore
document.querySelector('#send_but').click();
}
}
/**
* @param {QuickReply} qr
* @param {string} [message] - optional altered message to be used
* @param {SlashCommandScope} [scope] - optional scope to be used when running the command
*/
async execute(qr, message = null, isAutoExecute = false, scope = null) {
return this.executeWithOptions(qr, {
message,
isAutoExecute,
scope,
});
}
addQuickReply(data = {}) {
const id = Math.max(this.idIndex, this.qrList.reduce((max,qr)=>Math.max(max,qr.id),0)) + 1;
data.id =
this.idIndex = id + 1;
const qr = QuickReply.from(data);
this.qrList.push(qr);
this.hookQuickReply(qr);
if (this.settingsDom) {
this.renderSettingsItem(qr, this.qrList.length - 1);
}
if (this.dom) {
this.dom.append(qr.render());
}
this.save();
return qr;
}
addQuickReplyFromText(qrJson) {
let data;
if (qrJson) {
try {
data = JSON.parse(qrJson ?? '{}');
delete data.id;
} catch {
// not JSON data
}
if (data) {
// JSON data
if (data.label === undefined || data.message === undefined) {
// not a QR
toastr.error('Not a QR.');
return;
}
} else {
// no JSON, use plaintext as QR message
data = { message: qrJson };
}
} else {
data = {};
}
const newQr = this.addQuickReply(data);
return newQr;
}
/**
*
* @param {QuickReply} qr
*/
hookQuickReply(qr) {
// @ts-ignore
qr.onDebug = ()=>this.debug(qr);
qr.onExecute = (_, options)=>this.executeWithOptions(qr, options);
qr.onDelete = ()=>this.removeQuickReply(qr);
qr.onUpdate = ()=>this.save();
qr.onInsertBefore = (qrJson)=>{
this.addQuickReplyFromText(qrJson);
const newQr = this.qrList.pop();
this.qrList.splice(this.qrList.indexOf(qr), 0, newQr);
if (qr.settingsDom) {
qr.settingsDom.insertAdjacentElement('beforebegin', newQr.settingsDom);
}
this.save();
};
qr.onTransfer = async()=>{
/**@type {HTMLSelectElement} */
let sel;
let isCopy = false;
const dom = document.createElement('div'); {
dom.classList.add('qr--transferModal');
const title = document.createElement('h3'); {
title.textContent = 'Transfer Quick Reply';
dom.append(title);
}
const subTitle = document.createElement('h4'); {
const entryName = qr.label;
const bookName = this.name;
subTitle.textContent = `${bookName}: ${entryName}`;
dom.append(subTitle);
}
sel = document.createElement('select'); {
sel.classList.add('qr--transferSelect');
sel.setAttribute('autofocus', '1');
const noOpt = document.createElement('option'); {
noOpt.value = '';
noOpt.textContent = '-- Select QR Set --';
sel.append(noOpt);
}
for (const qrs of QuickReplySet.list) {
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
sel.append(opt);
}
}
sel.addEventListener('keyup', (evt)=>{
if (evt.key == 'Shift') {
// @ts-ignore
(dlg.dom ?? dlg.dlg).classList.remove('qr--isCopy');
return;
}
});
sel.addEventListener('keydown', (evt)=>{
if (evt.key == 'Shift') {
// @ts-ignore
(dlg.dom ?? dlg.dlg).classList.add('qr--isCopy');
return;
}
if (!evt.ctrlKey && !evt.altKey && evt.key == 'Enter') {
evt.preventDefault();
if (evt.shiftKey) isCopy = true;
dlg.completeAffirmative();
}
});
dom.append(sel);
}
const hintP = document.createElement('p'); {
const hint = document.createElement('small'); {
hint.textContent = 'Type or arrows to select QR Set. Enter to transfer. Shift+Enter to copy.';
hintP.append(hint);
}
dom.append(hintP);
}
}
const dlg = new Popup(dom, POPUP_TYPE.CONFIRM, null, { okButton:'Transfer', cancelButton:'Cancel' });
const copyBtn = document.createElement('div'); {
copyBtn.classList.add('qr--copy');
copyBtn.classList.add('menu_button');
copyBtn.textContent = 'Copy';
copyBtn.addEventListener('click', ()=>{
isCopy = true;
dlg.completeAffirmative();
});
// @ts-ignore
(dlg.ok ?? dlg.okButton).insertAdjacentElement('afterend', copyBtn);
}
const prom = dlg.show();
sel.focus();
await prom;
if (dlg.result == POPUP_RESULT.AFFIRMATIVE) {
const qrs = QuickReplySet.list.find(it=>it.name == sel.value);
qrs.addQuickReply(qr.toJSON());
if (!isCopy) {
qr.delete();
}
}
};
}
removeQuickReply(qr) {
this.qrList.splice(this.qrList.indexOf(qr), 1);
this.save();
}
toJSON() {
return {
version: 2,
name: this.name,
disableSend: this.disableSend,
placeBeforeInput: this.placeBeforeInput,
injectInput: this.injectInput,
color: this.color,
onlyBorderColor: this.onlyBorderColor,
qrList: this.qrList,
idIndex: this.idIndex,
};
}
async performSave() {
const response = await fetch('/api/quick-replies/save', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(this),
});
if (response.ok) {
this.rerender();
} else {
warn(`Failed to save Quick Reply Set: ${this.name}`);
console.error('QR could not be saved', response);
}
}
async delete() {
const response = await fetch('/api/quick-replies/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(this),
});
if (response.ok) {
this.unrender();
const idx = QuickReplySet.list.indexOf(this);
if (idx > -1) {
QuickReplySet.list.splice(idx, 1);
this.isDeleted = true;
} else {
warn(`Deleted Quick Reply Set was not found in the list of sets: ${this.name}`);
}
} else {
warn(`Failed to delete Quick Reply Set: ${this.name}`);
}
}
}

View File

@@ -0,0 +1,129 @@
import { QuickReplySet } from './QuickReplySet.js';
export class QuickReplySetLink {
static from(props) {
props.set = QuickReplySet.get(props.set);
/**@type {QuickReplySetLink}*/
const instance = Object.assign(new this(), props);
return instance;
}
/**@type {QuickReplySet}*/ set;
/**@type {Boolean}*/ isVisible = true;
/**@type {Number}*/ index;
/**@type {Function}*/ onUpdate;
/**@type {Function}*/ onRequestEditSet;
/**@type {Function}*/ onDelete;
/**@type {HTMLElement}*/ settingsDom;
renderSettings(idx) {
this.index = idx;
const item = document.createElement('div'); {
this.settingsDom = item;
item.classList.add('qr--item');
item.setAttribute('data-order', String(this.index));
const drag = document.createElement('div'); {
drag.classList.add('drag-handle');
drag.classList.add('ui-sortable-handle');
drag.textContent = '☰';
item.append(drag);
}
const set = document.createElement('select'); {
set.classList.add('qr--set');
// fix for jQuery sortable breaking childrens' touch events
set.addEventListener('touchstart', (evt)=>evt.stopPropagation());
set.addEventListener('change', ()=>{
this.set = QuickReplySet.get(set.value);
this.update();
});
QuickReplySet.list.toSorted((a,b)=>a.name.toLowerCase().localeCompare(b.name.toLowerCase())).forEach(qrs=>{
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
opt.selected = qrs == this.set;
set.append(opt);
}
});
item.append(set);
}
const visible = document.createElement('label'); {
visible.classList.add('qr--visible');
visible.title = 'Show buttons';
const cb = document.createElement('input'); {
cb.type = 'checkbox';
cb.checked = this.isVisible;
cb.addEventListener('click', ()=>{
this.isVisible = cb.checked;
this.update();
});
visible.append(cb);
}
visible.append('Buttons');
item.append(visible);
}
const edit = document.createElement('div'); {
edit.classList.add('menu_button');
edit.classList.add('menu_button_icon');
edit.classList.add('fa-solid');
edit.classList.add('fa-pencil');
edit.title = 'Edit quick reply set';
edit.addEventListener('click', ()=>this.requestEditSet());
item.append(edit);
}
const del = document.createElement('div'); {
del.classList.add('qr--del');
del.classList.add('menu_button');
del.classList.add('menu_button_icon');
del.classList.add('fa-solid');
del.classList.add('fa-trash-can');
del.title = 'Remove quick reply set';
del.addEventListener('click', ()=>this.delete());
item.append(del);
}
}
return this.settingsDom;
}
unrenderSettings() {
this.settingsDom?.remove();
this.settingsDom = null;
}
update() {
if (this.onUpdate) {
this.onUpdate(this);
}
}
requestEditSet() {
if (this.onRequestEditSet) {
this.onRequestEditSet(this.set);
}
}
delete() {
this.unrenderSettings();
if (this.onDelete) {
this.onDelete();
}
}
toJSON() {
return {
set: this.set.name,
isVisible: this.isVisible,
};
}
}

View File

@@ -0,0 +1,112 @@
import { chat_metadata, saveSettingsDebounced } from '../../../../script.js';
import { extension_settings, saveMetadataDebounced } from '../../../extensions.js';
import { QuickReplyConfig } from './QuickReplyConfig.js';
export class QuickReplySettings {
static from(props) {
props.config = QuickReplyConfig.from(props.config);
props.characterConfigs = props.characterConfigs ?? {};
for (const key of Object.keys(props.characterConfigs)) {
props.characterConfigs[key] = QuickReplyConfig.from(props.characterConfigs[key]);
}
const instance = Object.assign(new this(), props);
instance.init();
return instance;
}
/**@type {Boolean}*/ isEnabled = false;
/**@type {Boolean}*/ isCombined = false;
/**@type {Boolean}*/ isPopout = false;
/**@type {Boolean}*/ showPopoutButton = true;
/**@type {QuickReplyConfig}*/ config;
/**@type {{[key:string]: QuickReplyConfig}}*/ characterConfigs = {};
/**@type {QuickReplyConfig}*/ _chatConfig;
/**@type {QuickReplyConfig}*/ _charConfig;
get chatConfig() {
return this._chatConfig;
}
set chatConfig(value) {
if (this._chatConfig != value) {
this.unhookConfig(this._chatConfig);
this._chatConfig = value;
this.hookConfig(this._chatConfig);
}
}
get charConfig() {
return this._charConfig;
}
set charConfig(value) {
if (this._charConfig != value) {
this.unhookConfig(this._charConfig);
this._charConfig = value;
this.hookConfig(this._charConfig);
}
}
/**@type {Function}*/ onSave;
/**@type {Function}*/ onRequestEditSet;
init() {
this.hookConfig(this.config);
this.hookConfig(this.chatConfig);
this.hookConfig(this.charConfig);
}
hookConfig(config) {
if (config) {
config.onUpdate = ()=>this.save();
config.onRequestEditSet = (qrs)=>this.requestEditSet(qrs);
}
}
unhookConfig(config) {
if (config) {
config.onUpdate = null;
config.onRequestEditSet = null;
}
}
save() {
extension_settings.quickReplyV2 = this.toJSON();
saveSettingsDebounced();
if (this.chatConfig) {
chat_metadata.quickReply = this.chatConfig.toJSON();
saveMetadataDebounced();
}
if (this.onSave) {
this.onSave();
}
}
requestEditSet(qrs) {
if (this.onRequestEditSet) {
this.onRequestEditSet(qrs);
}
}
toJSON() {
const characterConfigs = {};
for (const key of Object.keys(this.characterConfigs)) {
if (this.characterConfigs[key]?.setList?.length === 0) {
continue;
}
characterConfigs[key] = this.characterConfigs[key].toJSON();
}
return {
isEnabled: this.isEnabled,
isCombined: this.isCombined,
isPopout: this.isPopout,
showPopoutButton: this.showPopoutButton,
config: this.config,
characterConfigs,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
import { animation_duration } from '../../../../../script.js';
import { dragElement } from '../../../../RossAscends-mods.js';
import { loadMovingUIState } from '../../../../power-user.js';
import { QuickReplySettings } from '../QuickReplySettings.js';
export class ButtonUi {
/** @type {QuickReplySettings} */ settings;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ popoutDom;
constructor(/**@type {QuickReplySettings}*/settings) {
this.settings = settings;
}
render() {
if (this.settings.isPopout) {
return this.renderPopout();
}
return this.renderBar();
}
unrender() {
this.dom?.remove();
this.dom = null;
this.popoutDom?.remove();
this.popoutDom = null;
}
show() {
if (!this.settings.isEnabled) return;
if (this.settings.isPopout) {
document.body.append(this.render());
loadMovingUIState();
$(this.render()).fadeIn(animation_duration);
dragElement($(this.render()));
} else {
const sendForm = document.querySelector('#send_form');
if (sendForm.children.length > 0) {
sendForm.children[0].insertAdjacentElement('beforebegin', this.render());
} else {
sendForm.append(this.render());
}
}
}
hide() {
this.unrender();
}
refresh() {
this.hide();
this.show();
}
renderBar() {
if (!this.dom) {
let buttonHolder;
const root = document.createElement('div'); {
this.dom = root;
buttonHolder = root;
root.id = 'qr--bar';
root.classList.add('flex-container');
root.classList.add('flexGap5');
if (this.settings.showPopoutButton) {
root.classList.add('popoutVisible');
const popout = document.createElement('div'); {
popout.id = 'qr--popoutTrigger';
popout.classList.add('menu_button');
popout.classList.add('fa-solid');
popout.classList.add('fa-window-restore');
popout.addEventListener('click', ()=>{
this.settings.isPopout = true;
this.refresh();
this.settings.save();
});
root.append(popout);
}
}
if (this.settings.isCombined) {
const buttons = document.createElement('div'); {
buttonHolder = buttons;
buttons.classList.add('qr--buttons');
root.append(buttons);
}
}
[...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? []), ...(this.settings.charConfig?.setList ?? [])]
.filter(link=>link.isVisible)
.forEach(link=>buttonHolder.append(link.set.render()))
;
}
}
return this.dom;
}
renderPopout() {
if (!this.popoutDom) {
let buttonHolder;
const root = document.createElement('div'); {
this.popoutDom = root;
root.id = 'qr--popout';
root.classList.add('qr--popout');
root.classList.add('draggable');
const head = document.createElement('div'); {
head.classList.add('qr--header');
root.append(head);
const controls = document.createElement('div'); {
controls.classList.add('qr--controls');
controls.classList.add('panelControlBar');
controls.classList.add('flex-container');
const drag = document.createElement('div'); {
drag.id = 'qr--popoutheader';
drag.classList.add('fa-solid');
drag.classList.add('fa-grip');
drag.classList.add('drag-grabber');
drag.classList.add('hoverglow');
controls.append(drag);
}
const close = document.createElement('div'); {
close.classList.add('qr--close');
close.classList.add('fa-solid');
close.classList.add('fa-circle-xmark');
close.classList.add('hoverglow');
close.addEventListener('click', ()=>{
this.settings.isPopout = false;
this.refresh();
this.settings.save();
});
controls.append(close);
}
head.append(controls);
}
}
const body = document.createElement('div'); {
buttonHolder = body;
body.classList.add('qr--body');
if (this.settings.isCombined) {
const buttons = document.createElement('div'); {
buttonHolder = buttons;
buttons.classList.add('qr--buttons');
body.append(buttons);
}
}
[...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? []), ...(this.settings.charConfig?.setList ?? [])]
.filter(link=>link.isVisible)
.forEach(link=>buttonHolder.append(link.set.render()))
;
root.append(body);
}
}
}
return this.popoutDom;
}
}

View File

@@ -0,0 +1,548 @@
import { Popup } from '../../../../popup.js';
import { getSortableDelay } from '../../../../utils.js';
import { log, warn } from '../../index.js';
import { QuickReply } from '../QuickReply.js';
import { QuickReplySet } from '../QuickReplySet.js';
import { QuickReplySettings } from '../QuickReplySettings.js';
export class SettingsUi {
/** @type {QuickReplySettings} */ settings;
/** @type {HTMLElement} */ template;
/** @type {HTMLElement} */ dom;
/**@type {HTMLInputElement}*/ isEnabled;
/**@type {HTMLInputElement}*/ isCombined;
/**@type {HTMLInputElement}*/ showPopoutButton;
/**@type {HTMLElement}*/ globalSetList;
/**@type {HTMLElement}*/ chatSetList;
/**@type {HTMLElement}*/ characterSetList;
/**@type {QuickReplySet}*/ currentQrSet;
/**@type {HTMLInputElement}*/ disableSend;
/**@type {HTMLInputElement}*/ placeBeforeInput;
/**@type {HTMLInputElement}*/ injectInput;
/**@type {HTMLInputElement}*/ color;
/**@type {HTMLInputElement}*/ onlyBorderColor;
/**@type {HTMLSelectElement}*/ currentSet;
constructor(/**@type {QuickReplySettings}*/settings) {
this.settings = settings;
settings.onRequestEditSet = (qrs) => this.selectQrSet(qrs);
}
rerender() {
if (!this.dom) return;
const content = this.dom.querySelector('.inline-drawer-content');
content.innerHTML = '';
// @ts-ignore
Array.from(this.template.querySelector('.inline-drawer-content').cloneNode(true).children).forEach(el=>{
content.append(el);
});
this.prepareDom();
}
unrender() {
this.dom?.remove();
this.dom = null;
}
async render() {
if (!this.dom) {
const response = await fetch('/scripts/extensions/quick-reply/html/settings.html', { cache: 'no-store' });
if (response.ok) {
this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--settings');
// @ts-ignore
this.dom = this.template.cloneNode(true);
this.prepareDom();
} else {
warn('failed to fetch settings template');
}
}
return this.dom;
}
prepareGeneralSettings() {
// general settings
this.isEnabled = this.dom.querySelector('#qr--isEnabled');
this.isEnabled.checked = this.settings.isEnabled;
this.isEnabled.addEventListener('click', ()=>this.onIsEnabled());
this.isCombined = this.dom.querySelector('#qr--isCombined');
this.isCombined.checked = this.settings.isCombined;
this.isCombined.addEventListener('click', ()=>this.onIsCombined());
this.showPopoutButton = this.dom.querySelector('#qr--showPopoutButton');
this.showPopoutButton.checked = this.settings.showPopoutButton;
this.showPopoutButton.addEventListener('click', ()=>this.onShowPopoutButton());
}
prepareGlobalSetList() {
const dom = this.template.querySelector('#qr--global');
const clone = dom.cloneNode(true);
// @ts-ignore
this.settings.config.renderSettingsInto(clone);
this.dom.querySelector('#qr--global').replaceWith(clone);
}
prepareChatSetList() {
const dom = this.template.querySelector('#qr--chat');
const clone = dom.cloneNode(true);
if (this.settings.chatConfig) {
// @ts-ignore
this.settings.chatConfig.renderSettingsInto(clone);
} else {
const info = document.createElement('div'); {
info.textContent = 'No active chat.';
// @ts-ignore
clone.append(info);
}
}
this.dom.querySelector('#qr--chat').replaceWith(clone);
}
prepareCharacterSetList() {
const dom = this.template.querySelector('#qr--character');
const clone = /** @type {HTMLElement} */ (dom.cloneNode(true));
if (!this.settings.charConfig) {
const setListContainer = /** @type {HTMLElement} */ (clone.querySelector('.qr--setList'));
setListContainer.innerHTML = '';
const info = document.createElement('div');
info.textContent = 'No character is currently loaded.';
setListContainer.append(info);
} else {
// Let the config object handle its own rendering. It will render an empty list if there are no sets,
// but the "add" button will always be functional.
this.settings.charConfig.renderSettingsInto(clone);
}
// Replace the old DOM element with our newly prepared clone.
this.dom.querySelector('#qr--character').replaceWith(clone);
}
prepareQrEditor() {
// qr editor
this.dom.querySelector('#qr--set-rename').addEventListener('click', async () => this.renameQrSet());
this.dom.querySelector('#qr--set-new').addEventListener('click', async()=>this.addQrSet());
/**@type {HTMLInputElement}*/
const importFile = this.dom.querySelector('#qr--set-importFile');
importFile.addEventListener('change', async()=>{
await this.importQrSet(importFile.files);
importFile.value = null;
});
this.dom.querySelector('#qr--set-import').addEventListener('click', ()=>importFile.click());
this.dom.querySelector('#qr--set-export').addEventListener('click', async () => this.exportQrSet());
this.dom.querySelector('#qr--set-duplicate').addEventListener('click', async () => this.duplicateQrSet());
this.dom.querySelector('#qr--set-delete').addEventListener('click', async()=>this.deleteQrSet());
this.dom.querySelector('#qr--set-add').addEventListener('click', async()=>{
this.currentQrSet.addQuickReply();
});
this.dom.querySelector('#qr--set-paste').addEventListener('click', async()=>{
const text = await navigator.clipboard.readText();
this.currentQrSet.addQuickReplyFromText(text);
});
this.dom.querySelector('#qr--set-importQr').addEventListener('click', async()=>{
const inp = document.createElement('input'); {
inp.type = 'file';
inp.accept = '.json';
inp.addEventListener('change', async()=>{
if (inp.files.length > 0) {
for (const file of inp.files) {
const text = await file.text();
this.currentQrSet.addQuickReply(JSON.parse(text));
}
}
});
inp.click();
}
});
this.qrList = this.dom.querySelector('#qr--set-qrList');
this.currentSet = this.dom.querySelector('#qr--set');
this.currentSet.addEventListener('change', ()=>this.onQrSetChange());
QuickReplySet.list.toSorted((a,b)=>a.name.toLowerCase().localeCompare(b.name.toLowerCase())).forEach(qrs=>{
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
this.currentSet.append(opt);
}
});
this.disableSend = this.dom.querySelector('#qr--disableSend');
this.disableSend.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.disableSend = this.disableSend.checked;
qrs.save();
});
this.placeBeforeInput = this.dom.querySelector('#qr--placeBeforeInput');
this.placeBeforeInput.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.placeBeforeInput = this.placeBeforeInput.checked;
qrs.save();
});
this.injectInput = this.dom.querySelector('#qr--injectInput');
this.injectInput.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.injectInput = this.injectInput.checked;
qrs.save();
});
let initialColorChange = true;
this.color = this.dom.querySelector('#qr--color');
// @ts-ignore
this.color.color = this.currentQrSet?.color ?? 'transparent';
this.color.addEventListener('change', (evt)=>{
if (!this.dom.closest('body')) return;
const qrs = this.currentQrSet;
if (initialColorChange) {
initialColorChange = false;
// @ts-ignore
this.color.color = qrs.color;
return;
}
// @ts-ignore
qrs.color = evt.detail.rgb;
qrs.save();
this.currentQrSet.updateColor();
});
// @ts-ignore
this.dom.querySelector('#qr--colorClear').addEventListener('click', (evt)=>{
const qrs = this.currentQrSet;
// @ts-ignore
this.color.color = 'transparent';
qrs.save();
this.currentQrSet.updateColor();
});
this.onlyBorderColor = this.dom.querySelector('#qr--onlyBorderColor');
this.onlyBorderColor.addEventListener('click', ()=>{
const qrs = this.currentQrSet;
qrs.onlyBorderColor = this.onlyBorderColor.checked;
qrs.save();
this.currentQrSet.updateColor();
});
this.onQrSetChange();
}
onQrSetChange() {
this.currentQrSet = QuickReplySet.get(this.currentSet.value) ?? new QuickReplySet();
this.disableSend.checked = this.currentQrSet.disableSend;
this.placeBeforeInput.checked = this.currentQrSet.placeBeforeInput;
this.injectInput.checked = this.currentQrSet.injectInput;
// @ts-ignore
this.color.color = this.currentQrSet.color ?? 'transparent';
this.onlyBorderColor.checked = this.currentQrSet.onlyBorderColor;
this.qrList.innerHTML = '';
const qrsDom = this.currentQrSet.renderSettings();
this.qrList.append(qrsDom);
// @ts-ignore
$(qrsDom).sortable({
delay: getSortableDelay(),
handle: '.drag-handle',
stop: ()=>this.onQrListSort(),
});
}
prepareDom() {
this.prepareGeneralSettings();
this.prepareGlobalSetList();
this.prepareChatSetList();
this.prepareCharacterSetList();
this.prepareQrEditor();
}
async onIsEnabled() {
this.settings.isEnabled = this.isEnabled.checked;
this.settings.save();
}
async onIsCombined() {
this.settings.isCombined = this.isCombined.checked;
this.settings.save();
}
async onShowPopoutButton() {
this.settings.showPopoutButton = this.showPopoutButton.checked;
this.settings.save();
}
async onGlobalSetListSort() {
this.settings.config.setList = Array.from(this.globalSetList.children).map((it,idx)=>{
const set = this.settings.config.setList[Number(it.getAttribute('data-order'))];
it.setAttribute('data-order', String(idx));
return set;
});
this.settings.save();
}
async onChatSetListSort() {
this.settings.chatConfig.setList = Array.from(this.chatSetList.children).map((it,idx)=>{
const set = this.settings.chatConfig.setList[Number(it.getAttribute('data-order'))];
it.setAttribute('data-order', String(idx));
return set;
});
this.settings.save();
}
updateOrder(list) {
Array.from(list.children).forEach((it,idx)=>{
it.setAttribute('data-order', idx);
});
}
async onQrListSort() {
this.currentQrSet.qrList = Array.from(this.qrList.querySelectorAll('.qr--set-item')).map((it,idx)=>{
const qr = this.currentQrSet.qrList.find(qr=>qr.id == Number(it.getAttribute('data-id')));
it.setAttribute('data-order', String(idx));
return qr;
});
this.currentQrSet.save();
}
async deleteQrSet() {
const confirmed = await Popup.show.confirm('Delete Quick Reply Set', `Are you sure you want to delete the Quick Reply Set "${this.currentQrSet.name}"?<br>This cannot be undone.`);
if (confirmed) {
await this.doDeleteQrSet(this.currentQrSet);
this.rerender();
}
}
async doDeleteQrSet(qrs) {
await qrs.delete();
//TODO (HACK) should just bubble up from QuickReplySet.delete() but that would require proper or at least more comples onDelete listeners
for (let i = this.settings.config.setList.length - 1; i >= 0; i--) {
if (this.settings.config.setList[i].set == qrs) {
this.settings.config.setList.splice(i, 1);
}
}
if (this.settings.chatConfig) {
for (let i = this.settings.chatConfig.setList.length - 1; i >= 0; i--) {
if (this.settings.chatConfig.setList[i].set == qrs) {
this.settings.chatConfig.setList.splice(i, 1);
}
}
}
if (this.settings.charConfig) {
for (let i = this.settings.charConfig.setList.length - 1; i >= 0; i--) {
if (this.settings.charConfig.setList[i].set == qrs) {
this.settings.charConfig.setList.splice(i, 1);
}
}
}
this.settings.save();
}
async renameQrSet() {
const newName = await Popup.show.input('Rename Quick Reply Set', 'Enter a new name:', this.currentQrSet.name);
if (newName && newName.length > 0) {
const existingSet = QuickReplySet.get(newName);
if (existingSet) {
toastr.error(`A Quick Reply Set named "${newName}" already exists.`);
return;
}
const oldName = this.currentQrSet.name;
this.currentQrSet.name = newName;
await this.currentQrSet.save();
// Update it in both set lists
this.settings.config.setList.forEach(set => {
if (set.set.name === oldName) {
set.set.name = newName;
}
});
this.settings.chatConfig?.setList.forEach(set => {
if (set.set.name === oldName) {
set.set.name = newName;
}
});
this.settings.charConfig?.setList.forEach(set => {
if (set.set.name === oldName) {
set.set.name = newName;
}
});
this.settings.save();
// Update the option in the current selected QR dropdown. All others will be refreshed via the prepare calls below.
/** @type {HTMLOptionElement} */
const option = this.currentSet.querySelector(`#qr--set option[value="${oldName}"]`);
option.value = newName;
option.textContent = newName;
this.currentSet.value = newName;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
this.prepareCharacterSetList();
console.info(`Quick Reply Set renamed from ""${oldName}" to "${newName}".`);
}
}
async addQrSet() {
const name = await Popup.show.input('Create a new Quick Reply Set', 'Enter a name for the new Quick Reply Set:');
if (name && name.length > 0) {
const oldQrs = QuickReplySet.get(name);
if (oldQrs) {
const replace = Popup.show.confirm('Replace existing World Info', `A Quick Reply Set named "${name}" already exists.<br>Do you want to overwrite the existing Quick Reply Set?<br>The existing set will be deleted. This cannot be undone.`);
if (replace) {
const idx = QuickReplySet.list.indexOf(oldQrs);
await this.doDeleteQrSet(oldQrs);
const qrs = new QuickReplySet();
qrs.name = name;
qrs.addQuickReply();
QuickReplySet.list.splice(idx, 0, qrs);
this.rerender();
this.currentSet.value = name;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
this.prepareCharacterSetList();
}
} else {
const qrs = new QuickReplySet();
qrs.name = name;
qrs.addQuickReply();
const idx = QuickReplySet.list.findIndex(it=>it.name.toLowerCase().localeCompare(name.toLowerCase()) == 1);
if (idx > -1) {
QuickReplySet.list.splice(idx, 0, qrs);
} else {
QuickReplySet.list.push(qrs);
}
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
if (idx > -1) {
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
} else {
this.currentSet.append(opt);
}
}
this.currentSet.value = name;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
this.prepareCharacterSetList();
}
}
}
async importQrSet(/**@type {FileList}*/files) {
for (let i = 0; i < files.length; i++) {
await this.importSingleQrSet(files.item(i));
}
}
async importSingleQrSet(/**@type {File}*/file) {
log('FILE', file);
try {
const text = await file.text();
const props = JSON.parse(text);
if (!Number.isInteger(props.version) || typeof props.name != 'string') {
toastr.error(`The file "${file.name}" does not appear to be a valid quick reply set.`);
warn(`The file "${file.name}" does not appear to be a valid quick reply set.`);
} else {
/**@type {QuickReplySet}*/
const qrs = QuickReplySet.from(JSON.parse(JSON.stringify(props)));
qrs.qrList = props.qrList.map(it=>QuickReply.from(it));
qrs.init();
const oldQrs = QuickReplySet.get(props.name);
if (oldQrs) {
const replace = Popup.show.confirm('Replace existing World Info', `A Quick Reply Set named "${name}" already exists.<br>Do you want to overwrite the existing Quick Reply Set?<br>The existing set will be deleted. This cannot be undone.`);
if (replace) {
const idx = QuickReplySet.list.indexOf(oldQrs);
await this.doDeleteQrSet(oldQrs);
QuickReplySet.list.splice(idx, 0, qrs);
await qrs.save();
this.rerender();
this.currentSet.value = qrs.name;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
this.prepareCharacterSetList();
}
} else {
const idx = QuickReplySet.list.findIndex(it=>it.name.toLowerCase().localeCompare(qrs.name.toLowerCase()) == 1);
if (idx > -1) {
QuickReplySet.list.splice(idx, 0, qrs);
} else {
QuickReplySet.list.push(qrs);
}
await qrs.save();
const opt = document.createElement('option'); {
opt.value = qrs.name;
opt.textContent = qrs.name;
if (idx > -1) {
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
} else {
this.currentSet.append(opt);
}
}
this.currentSet.value = qrs.name;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
this.prepareCharacterSetList();
}
}
} catch (ex) {
warn(ex);
toastr.error(`Failed to import "${file.name}":\n\n${ex.message}`);
}
}
exportQrSet() {
const blob = new Blob([JSON.stringify(this.currentQrSet)], { type:'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); {
a.href = url;
a.download = `${this.currentQrSet.name}.json`;
a.click();
}
URL.revokeObjectURL(url);
}
async duplicateQrSet() {
const newName = await Popup.show.input('Duplicate Quick Reply Set', 'Enter a name for the new Quick Reply Set:', `${this.currentQrSet.name} (Copy)`);
if (newName && newName.length > 0) {
const existingSet = QuickReplySet.get(newName);
if (existingSet) {
toastr.error(`A Quick Reply Set named "${newName}" already exists.`);
return;
}
const newQrSet = QuickReplySet.from(this.currentQrSet.toJSON());
newQrSet.name = newName;
newQrSet.qrList = this.currentQrSet.qrList.map(qr => QuickReply.from(qr.toJSON()));
newQrSet.init();
const idx = QuickReplySet.list.findIndex(it => it.name.toLowerCase().localeCompare(newName.toLowerCase()) == 1);
if (idx > -1) {
QuickReplySet.list.splice(idx, 0, newQrSet);
} else {
QuickReplySet.list.push(newQrSet);
}
const opt = document.createElement('option'); {
opt.value = newQrSet.name;
opt.textContent = newQrSet.name;
if (idx > -1) {
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
} else {
this.currentSet.append(opt);
}
}
this.currentSet.value = newName;
this.onQrSetChange();
this.prepareGlobalSetList();
this.prepareChatSetList();
this.prepareCharacterSetList();
}
}
selectQrSet(qrs) {
this.currentSet.value = qrs.name;
this.onQrSetChange();
}
}

View File

@@ -0,0 +1,130 @@
import { QuickReply } from '../../QuickReply.js';
import { QuickReplySet } from '../../QuickReplySet.js';
import { MenuHeader } from './MenuHeader.js';
import { MenuItem } from './MenuItem.js';
export class ContextMenu {
/**@type {MenuItem[]}*/ itemList = [];
/**@type {Boolean}*/ isActive = false;
/**@type {HTMLElement}*/ root;
/**@type {HTMLElement}*/ menu;
constructor(/**@type {QuickReply}*/qr) {
// this.itemList = items;
this.itemList = this.build(qr).children;
this.itemList.forEach(item => {
item.onExpand = () => {
this.itemList.filter(it => it !== item)
.forEach(it => it.collapse());
};
});
}
/**
* @param {QuickReply} qr
* @param {String} chainedMessage
* @param {QuickReplySet[]} hierarchy
* @param {String[]} labelHierarchy
*/
build(qr, chainedMessage = null, hierarchy = [], labelHierarchy = []) {
const tree = {
icon: qr.icon,
showLabel: qr.showLabel,
label: qr.label,
title: qr.title,
message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
children: [],
};
qr.contextList.forEach((cl) => {
if (!cl.set) return;
if (!hierarchy.includes(cl.set)) {
const nextHierarchy = [...hierarchy, cl.set];
const nextLabelHierarchy = [...labelHierarchy, tree.label];
tree.children.push(new MenuHeader(cl.set.name));
// If the Quick Reply's own set is added as a context menu,
// show only the sub-QRs that are Invisible but have an icon
// intent: allow a QR set to be assigned to one of its own QR buttons for a "burger" menu
// with "UI" QRs either in the bar or in the menu, and "library function" QRs still hidden.
// - QRs already visible on the bar are filtered out,
// - hidden QRs without an icon are filtered out,
// - hidden QRs **with an icon** are shown in the menu
// so everybody is happy
const qrsOwnSetAddedAsContextMenu = cl.set.qrList.includes(qr);
const visible = (subQr) => {
return qrsOwnSetAddedAsContextMenu
? subQr.isHidden && !!subQr.icon // yes .isHidden gets inverted here
: !subQr.isHidden;
};
cl.set.qrList.filter(visible).forEach(subQr => {
const subTree = this.build(subQr, cl.isChained ? tree.message : null, nextHierarchy, nextLabelHierarchy);
tree.children.push(new MenuItem(
subTree.icon,
subTree.showLabel,
subTree.label,
subTree.title,
subTree.message,
(evt) => {
evt.stopPropagation();
const finalQr = Object.assign(new QuickReply(), subQr);
finalQr.message = subTree.message.replace(/%%parent(-\d+)?%%/g, (_, index) => {
return nextLabelHierarchy.slice(parseInt(index ?? '-1'))[0];
});
cl.set.execute(finalQr);
},
subTree.children,
));
});
}
});
return tree;
}
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(null, null, label, null, 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,107 @@
import { SubMenu } from './SubMenu.js';
export class MenuItem {
/**@type {string}*/ icon;
/**@type {boolean}*/ showLabel;
/**@type {string}*/ label;
/**@type {string}*/ title;
/**@type {object}*/ value;
/**@type {function}*/ callback;
/**@type {MenuItem[]}*/ childList = [];
/**@type {SubMenu}*/ subMenu;
/**@type {HTMLElement}*/ root;
/**@type {function}*/ onExpand;
/**
*
* @param {?string} icon
* @param {?boolean} showLabel
* @param {string} label
* @param {?string} title Tooltip
* @param {object} value
* @param {function} callback
* @param {MenuItem[]} children
*/
constructor(icon, showLabel, label, title, value, callback, children = []) {
this.icon = icon;
this.showLabel = showLabel;
this.label = label;
this.title = title;
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');
// if a title/tooltip is set, add it, otherwise use the QR content
// same as for the main QR list
item.title = this.title || this.value;
if (this.callback) {
item.addEventListener('click', (evt) => this.callback(evt, this));
}
const icon = document.createElement('div'); {
icon.classList.add('qr--button-icon');
icon.classList.add('fa-solid');
if (!this.icon) icon.classList.add('qr--hidden');
else icon.classList.add(this.icon);
item.append(icon);
}
const lbl = document.createElement('div'); {
lbl.classList.add('qr--button-label');
if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');
lbl.textContent = this.label;
item.append(lbl);
}
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,66 @@
/**
* @typedef {import('./MenuItem.js').MenuItem} MenuItem
*/
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);
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
/* Styles for the debugger UI */
#regex_debugger_rules {
margin: 10px 0;
}
#regex_debugger_rules,
#regex_debugger_rules .sortable-list {
padding-left: 0;
}
.regex-debugger-rules-list {
position: relative;
}
.regex-debugger-rule {
display: flex;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 5px;
cursor: pointer;
background-color: var(--black30a);
gap: 5px;
margin-bottom: 5px;
}
#regex_debugger_run_test_header {
justify-content: space-between;
align-items: center;
}
#regex_debugger_expand_steps,
#regex_debugger_expand_final,
#regex_debugger_save_order {
position: absolute;
top: 0;
right: 0;
margin: 0;
}
.regex-debugger-rule:hover {
filter: brightness(1.1);
}
.regex-debugger-rule .handle {
cursor: grab;
margin-right: 5px;
}
.regex-debugger-rule .rule-details {
flex-grow: 1;
text-align: left;
display: flex;
align-items: baseline;
gap: 5px;
}
.regex-debugger-rule .rule-name {
font-weight: bold;
}
.regex-debugger-rule .rule-regex {
font-size: 0.8em;
opacity: 0.8;
font-family: var(--monoFontFamily);
}
.regex-debugger-rule .rule-scope {
font-size: 0.8em;
padding: 2px 6px;
border-radius: 5px;
background-color: var(--black30a);
margin-left: auto;
margin-right: 10px;
}
.regex-debugger-rule .menu_button {
margin: 0;
}
#regex_debugger_raw_input {
min-height: 1.8em;
}
#regex_debugger_steps_output {
min-height: 2em;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 5px;
padding: 5px;
background-color: var(--black30a);
font-family: var(--monoFontFamily);
font-size: 0.9em;
}
#regex_debugger_final_output {
min-height: 2em;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 5px;
padding: 5px;
background-color: var(--black30a);
white-space: pre-wrap;
word-break: break-word;
text-align: left;
}
.step-header {
margin-top: 10px;
margin-bottom: 5px;
}
.step-output {
white-space: pre-wrap;
word-break: break-all;
padding: 5px;
background-color: var(--black30a);
border-radius: 5px;
text-align: left;
}
/* Classes to replace inline styles */
.regex-debugger-no-rules {
padding: 10px;
text-align: center;
opacity: 0.8;
}
.regex-debugger-list-header {
font-weight: bold;
padding: 10px;
}
/* Styles for statistics */
.step-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.step-metrics {
font-size: 0.8em;
opacity: 0.8;
font-weight: normal;
}
.regex-debugger-summary {
padding: 8px;
margin-bottom: 10px;
border: 1px solid var(--SmartThemeBorderColor);
background-color: var(--black30a);
border-radius: 5px;
text-align: center;
font-size: 0.9em;
}
.regex-debugger-tester .results-header {
position: relative;
margin: 10px 0;
}
.regex-debugger-tester .radio_group {
text-align: left;
}
/* Styles for statistics and highlighting additions */
.step-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
/* Allow wrapping on small screens */
}
.step-metrics {
font-size: 0.8em;
opacity: 0.8;
font-weight: normal;
white-space: nowrap;
/* Prevent metrics from breaking line */
margin-left: 10px;
}
.regex-debugger-summary {
padding: 8px;
margin-bottom: 10px;
border: 1px solid var(--SmartThemeBorderColor);
background-color: var(--black30a);
border-radius: 5px;
text-align: center;
font-size: 0.9em;
}
/* New highlight color for added text */
mark.green_hl {
background-color: #28a745;
/* A standard green color */
color: white;
}
/* New highlight color for deleted text */
mark.red_hl {
background-color: #dc3545;
/* A standard red color */
color: white;
text-decoration: line-through;
}
/* Styles for the expanded view with navigation */
.expanded-regex-container {
display: flex;
height: 75vh;
/* Give the container a good height */
overflow: hidden;
}
.expanded-regex-nav {
flex: 0 0 200px;
/* Fixed width for the nav bar */
border-right: 1px solid var(--SmartThemeBorderColor);
padding: 5px;
overflow-y: auto;
background-color: var(--black30a);
}
.expanded-regex-nav a {
display: block;
padding: 6px 8px;
text-decoration: none;
color: var(--SmartThemeMainColor);
border-radius: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.expanded-regex-nav a:hover {
background-color: var(--background_hover_color);
}
.expanded-regex-nav a.active {
background-color: var(--highlight_color);
color: var(--text_color_black);
}
.expanded-regex-content {
flex-grow: 1;
overflow-y: auto;
padding-left: 10px;
}
#regex_debugger_render_mode {
padding-right: 20px;
margin-top: 5px;
}
.regex-popup-content {
white-space: pre-wrap;
word-break: break-all;
text-align: left;
}

View File

@@ -0,0 +1,179 @@
<div class="regex-debugger-container">
<!-- Rules List Column -->
<div class="regex-debugger-rules-list">
<h3>
<i class="fa-solid fa-list-ol"></i>
<span data-i18n="ext_regex_debugger_active_rules"
>Active Rules</span
>
</h3>
<div class="flex-container">
<button
id="regex_debugger_save_order"
class="menu_button menu_button_icon interactable"
data-i18n="[title]ext_regex_debugger_save_order_help"
title="Save current rule order"
tabindex="0"
>
<i class="fa-solid fa-floppy-disk"></i>
<span data-i18n="ext_regex_debugger_save_order"
>Save Order</span
>
</button>
</div>
<ul id="regex_debugger_rules" class="sortable-list">
<!-- Rules will be populated here by JavaScript -->
</ul>
</div>
<!-- Testing Area Column -->
<div class="regex-debugger-tester">
<h3>
<i class="fa-solid fa-vial"></i>
<span data-i18n="ext_regex_debugger_testing_area"
>Testing Area</span
>
</h3>
<div class="regex-debugger-io">
<div class="regex-debugger-input">
<label
for="regex_debugger_raw_input"
data-i18n="ext_regex_debugger_raw_input"
>Raw Input</label
>
<textarea
id="regex_debugger_raw_input"
class="text_pole autoSetHeight"
rows="4"
></textarea>
</div>
<div
id="regex_debugger_run_test_header"
class="flex-container"
>
<button
id="regex_debugger_run_test"
class="menu_button menu_button_icon interactable"
data-i18n="[title]ext_regex_debugger_run_test_help"
title="Run the test pipeline"
tabindex="0"
>
<i class="fa-solid fa-play"></i>
<span data-i18n="ext_regex_debugger_run_test"
>Run Test</span
>
</button>
<div class="flex-container gap10px">
<div class="radio_group">
<label
><input
type="radio"
name="display_mode"
value="replace"
checked
/>
<span data-i18n="ext_regex_debugger_display_replace"
>Replace</span
></label
>
<label
><input
type="radio"
name="display_mode"
value="highlight"
/>
<span
data-i18n="ext_regex_debugger_display_highlight"
>Highlight</span
></label
>
</div>
<select
id="regex_debugger_render_mode"
>
<option
value="text"
data-i18n="ext_regex_debugger_render_text"
>
Render as Text
</option>
<option
value="message"
data-i18n="ext_regex_debugger_render_message"
>
Render as Message
</option>
</select>
</div>
</div>
<div class="regex-debugger-results">
<div class="results-header">
<h4>
<i class="fa-solid fa-shoe-prints"></i>
<span data-i18n="ext_regex_debugger_step_by_step"
>Step-by-step Transformation</span
>
</h4>
<div
id="regex_debugger_expand_steps"
class="menu_button menu_button_icon"
data-i18n="[title]Expand view"
title="Expand view"
>
<i class="fa-solid fa-expand"></i>
</div>
</div>
<div id="regex_debugger_steps_output" class="results-box"></div>
<div class="results-header">
<h4>
<i class="fa-solid fa-flag-checkered"></i>
<span data-i18n="ext_regex_debugger_final_output"
>Final Output</span
>
</h4>
<div
id="regex_debugger_expand_final"
class="menu_button menu_button_icon"
data-i18n="[title]Expand view"
title="Expand view"
>
<i class="fa-solid fa-expand"></i>
</div>
</div>
<div
id="regex_debugger_final_output"
class="results-box final-output"
></div>
</div>
</div>
</div>
</div>
<!-- Template for a single rule item -->
<template id="regex_debugger_rule_template">
<li class="regex-debugger-rule" draggable="true">
<i class="fa-solid fa-grip-vertical handle"></i>
<label class="checkbox">
<input type="checkbox" class="rule-enabled" checked />
</label>
<div class="rule-details">
<span class="rule-name"></span>
<code class="rule-regex"></code>
<small class="rule-scope"></small>
</div>
<div class="menu_button menu_button_icon edit_rule" data-i18n="[title]Edit Rule" title="Edit Rule">
<i class="fa-solid fa-pencil"></i>
</div>
</li>
</template>
<!-- Template for a single transformation step -->
<template id="regex_debugger_step_template">
<div class="step-result">
<div class="step-header">
<strong></strong>
</div>
<pre class="step-output"></pre>
</div>
</template>

View File

@@ -0,0 +1,130 @@
<div class="regex_settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b data-i18n="ext_regex_title">
Regex
</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<div class="flex-container">
<div id="open_regex_editor" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_new_global_script_desc" title="New global regex script">
<i class="fa-solid fa-pen-to-square"></i>
<small data-i18n="ext_regex_new_global_script">+ Global</small>
</div>
<div id="open_preset_editor" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_new_preset_script_desc" title="New preset regex script">
<i class="fa-solid fa-sliders"></i>
<small data-i18n="ext_regex_new_preset_script">+ Preset</small>
</div>
<div id="open_scoped_editor" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_new_scoped_script_desc" title="New scoped regex script">
<i class="fa-solid fa-address-card"></i>
<small data-i18n="ext_regex_new_scoped_script">+ Scoped</small>
</div>
<div id="import_regex" class="menu_button menu_button_icon">
<i class="fa-solid fa-file-import"></i>
<small data-i18n="ext_regex_import_script">Import</small>
</div>
<input type="file" id="import_regex_file" hidden accept="*.json" multiple />
<label for="regex_bulk_edit" class="menu_button menu_button_icon">
<input id="regex_bulk_edit" type="checkbox" class="displayNone" />
<i class="fa-solid fa-edit"></i>
<small data-i18n="ext_regex_bulk_edit">Bulk Edit</small>
</label>
<div id="open_regex_debugger" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_debugger_desc" title="Advanced Regex Debugger">
<i class="fa-solid fa-bug-slash"></i>
<small data-i18n="ext_regex_debugger">Debugger</small>
</div>
</div>
<hr class="regex_bulk_operations_hr" />
<div class="regex_bulk_operations flex-container">
<div id="bulk_select_all_toggle" class="menu_button menu_button_icon" title="Toggle Select All">
<i class="fa-solid fa-check-double"></i>
</div>
<div id="bulk_enable_regex" class="menu_button menu_button_icon">
<i class="fa-solid fa-toggle-on"></i>
<small data-i18n="Enable">Enable</small>
</div>
<div id="bulk_disable_regex" class="menu_button menu_button_icon">
<i class="fa-solid fa-toggle-off"></i>
<small data-i18n="Disable">Disable</small>
</div>
<div id="bulk_regex_move_to_global" class="menu_button menu_button_icon" hidden>
<i class="fa-solid fa-globe"></i>
<small data-i18n="ext_regex_move_to_global">Move to global scripts</small>
</div>
<div id="bulk_regex_move_to_preset" class="menu_button menu_button_icon" hidden>
<i class="fa-solid fa-sliders"></i>
<small data-i18n="ext_regex_move_to_preset">Move to preset scripts</small>
</div>
<div id="bulk_regex_move_to_scoped" class="menu_button menu_button_icon" hidden>
<i class="fa-solid fa-address-card"></i>
<small data-i18n="ext_regex_move_to_scoped">Move to scoped scripts</small>
</div>
<div id="bulk_export_regex" class="menu_button menu_button_icon">
<i class="fa-solid fa-file-export"></i>
<small data-i18n="Export">Export</small>
</div>
<div id="bulk_delete_regex" class="menu_button menu_button_icon">
<i class="fa-solid fa-trash"></i>
<small data-i18n="Delete">Delete</small>
</div>
</div>
<hr />
<div id="regex_presets_block">
<div class="flex-container alignItemsBaseline">
<strong class="flex1" data-i18n="ext_regex_presets">Regex Presets</strong>
</div>
<small data-i18n="ext_regex_presets_desc">
Save and switch between groups of enabled regex scripts.
</small>
<div class="flex-container marginTop5">
<select id="regex_presets" class="text_pole flex1"></select>
<div id="regex_preset_create" class="menu_button fa-solid fa-file-circle-plus" data-i18n="[title]ext_regex_preset_create" title="Create a new regex preset"></div>
<div id="regex_preset_update" class="menu_button fa-solid fa-save" data-i18n="[title]ext_regex_preset_update" title="Update existing regex preset"></div>
<div id="regex_preset_apply" class="menu_button fa-solid fa-recycle" data-i18n="[title]ext_regex_preset_apply" title="Re-apply current preset"></div>
<div id="regex_preset_delete" class="menu_button fa-solid fa-trash" data-i18n="[title]ext_regex_preset_delete" title="Delete current preset"></div>
</div>
</div>
<hr />
<div id="global_scripts_block">
<div>
<strong data-i18n="ext_regex_global_scripts">Global Scripts</strong>
</div>
<small data-i18n="ext_regex_global_scripts_desc">
Available for all characters. Saved to local settings.
</small>
<div id="saved_regex_scripts" no-scripts-text="No scripts found" data-i18n="[no-scripts-text]No scripts found" class="flex-container regex-script-container flexFlowColumn"></div>
</div>
<hr />
<div id="preset_scripts_block">
<div class="flex-container alignItemsBaseline">
<strong class="flex1" data-i18n="ext_regex_preset_scripts">Preset Scripts</strong>
<label id="toggle_preset_regex" class="checkbox flex-container" for="regex_preset_toggle">
<input type="checkbox" id="regex_preset_toggle" class="enable_scoped" />
<span class="regex-toggle-on fa-solid fa-toggle-on fa-lg" data-i18n="[title]ext_regex_disallow_preset" title="Disallow using preset regex"></span>
<span class="regex-toggle-off fa-solid fa-toggle-off fa-lg" data-i18n="[title]ext_regex_allow_preset" title="Allow using preset regex"></span>
</label>
</div>
<small data-i18n="ext_regex_preset_scripts_desc">
Only available for this preset. Saved to the preset data.
</small>
<div id="saved_preset_scripts" no-scripts-text="No scripts found" data-i18n="[no-scripts-text]No scripts found" class="flex-container regex-script-container flexFlowColumn"></div>
</div>
<hr />
<div id="scoped_scripts_block">
<div class="flex-container alignItemsBaseline">
<strong class="flex1" data-i18n="ext_regex_scoped_scripts">Scoped Scripts</strong>
<label id="toggle_scoped_regex" class="checkbox flex-container" for="regex_scoped_toggle">
<input type="checkbox" id="regex_scoped_toggle" class="enable_scoped" />
<span class="regex-toggle-on fa-solid fa-toggle-on fa-lg" data-i18n="[title]ext_regex_disallow_scoped" title="Disallow using scoped regex"></span>
<span class="regex-toggle-off fa-solid fa-toggle-off fa-lg" data-i18n="[title]ext_regex_allow_scoped" title="Allow using scoped regex"></span>
</label>
</div>
<small data-i18n="ext_regex_scoped_scripts_desc">
Only available for this character. Saved to the card data.
</small>
<div id="saved_scoped_scripts" no-scripts-text="No scripts found" data-i18n="[no-scripts-text]No scripts found" class="flex-container regex-script-container flexFlowColumn"></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,164 @@
<div id="regex_editor_template">
<div class="regex_editor">
<h3 class="flex-container justifyCenter alignItemsBaseline">
<strong data-i18n="Regex Editor">Regex Editor</strong>
<a href="https://docs.sillytavern.app/extensions/regex/" class="notes-link" target="_blank" rel="noopener noreferrer">
<span class="note-link-span">?</span>
</a>
<div id="regex_test_mode_toggle" class="menu_button menu_button_icon">
<i class="fa-solid fa-bug fa-sm"></i>
<span class="menu_button_text" data-i18n="Test Mode">Test Mode</span>
</div>
</h3>
<small class="flex-container extensions_info" data-i18n="ext_regex_desc">
Regex is a tool to find/replace strings using regular expressions. If you want to learn more, click on the ? next to the title.
</small>
<hr />
<div id="regex_info_block_wrapper">
<div id="regex_info_block" class="info-block"></div>
<a id="regex_info_block_flags_hint" href="https://docs.sillytavern.app/extensions/regex/#flags" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-circle-info" data-i18n="[title]ext_regex_flags_help" title="Click here to learn more about regex flags."></i>
</a>
</div>
<div id="regex_test_mode" class="flex1 flex-container displayNone">
<div class="flex1">
<label class="title_restorable" for="regex_test_input">
<small data-i18n="Input">Input</small>
</label>
<textarea id="regex_test_input" class="text_pole textarea_compact" rows="4" data-i18n="[placeholder]ext_regex_test_input_placeholder" placeholder="Type here..."></textarea>
</div>
<div class="flex1">
<label class="title_restorable" for="regex_test_output">
<small data-i18n="Output">Output</small>
</label>
<textarea id="regex_test_output" class="text_pole textarea_compact" rows="4" data-i18n="[placeholder]ext_regex_output_placeholder" placeholder="Empty" readonly></textarea>
</div>
<hr>
</div>
<div class="flex-container flexFlowColumn">
<div class="flex1">
<label for="regex_script_name" class="title_restorable">
<small data-i18n="Script Name">Script Name</small>
</label>
<div>
<input class="regex_script_name text_pole textarea_compact" type="text" />
</div>
</div>
<div class="flex1">
<label for="find_regex" class="title_restorable">
<small data-i18n="Find Regex">Find Regex</small>
</label>
<div>
<input class="find_regex text_pole textarea_compact" type="text" />
</div>
</div>
<div class="flex1">
<label for="regex_replace_string" class="title_restorable">
<small data-i18n="Replace With">Replace With</small>
</label>
<div>
<textarea class="regex_replace_string text_pole wide100p textarea_compact" data-i18n="[placeholder]ext_regex_replace_string_placeholder" placeholder="Use {{match}} to include the matched text from the Find Regex, $1, $2, etc. for numbered capture groups, or $&lt;name&gt; for named capture groups." rows="3"></textarea>
</div>
</div>
<div class="flex1">
<label for="regex_trim_strings" class="title_restorable">
<small data-i18n="Trim Out">Trim Out</small>
</label>
<div>
<textarea class="regex_trim_strings text_pole wide100p textarea_compact" data-i18n="[placeholder]ext_regex_trim_placeholder" placeholder="Globally trims any unwanted parts from a regex match before replacement. Separate each element by an enter." rows="3"></textarea>
</div>
</div>
</div>
<div class="flex-container">
<div class="flex1 wi-enter-footer-text flex-container flexFlowColumn flexNoGap alignitemsstart">
<small data-i18n="ext_regex_affects">Affects</small>
<div data-i18n="[title]ext_regex_user_input_desc" title="Messages sent by the user.">
<label class="checkbox flex-container">
<input type="checkbox" name="replace_position" value="1">
<span data-i18n="ext_regex_user_input">User Input</span>
</label>
</div>
<div data-i18n="[title]ext_regex_ai_input_desc" title="Messages received from the Generation API.">
<label class="checkbox flex-container">
<input type="checkbox" name="replace_position" value="2">
<span data-i18n="ext_regex_ai_output">AI Output</span>
</label>
</div>
<div data-i18n="[title]ext_regex_slash_desc" title="Messages sent using STscript commands.">
<label class="checkbox flex-container">
<input type="checkbox" name="replace_position" value="3">
<span data-i18n="Slash Commands">Slash Commands</span>
</label>
</div>
<div data-i18n="[title]ext_regex_wi_desc" title="Lorebook/World Info entry contents. Requires 'Only Format Prompt' to be checked!">
<label class="checkbox flex-container">
<input type="checkbox" name="replace_position" value="5">
<span data-i18n="World Info">World Info</span>
</label>
</div>
<div data-i18n="[title]ext_regex_reasoning_desc" title="Reasoning block contents. When 'Only Format Prompt' is checked, it will also affect the reasoning contents added to the prompt.">
<label class="checkbox flex-container">
<input type="checkbox" name="replace_position" value="6">
<span data-i18n="Reasoning">Reasoning</span>
</label>
</div>
<div class="flex-container wide100p marginTop5">
<div class="flex1 flex-container flexNoGap">
<small data-i18n="[title]ext_regex_min_depth_desc" title="When applied to prompts or display, only affect messages that are at least N levels deep. 0 = last message, 1 = penultimate message, etc. System prompt and utility prompts are not affected. When blank / 'Unlimited' or -1, also affect message to continue on Continue.">
<span data-i18n="Min Depth">Min Depth</span>
<span class="fa-solid fa-circle-question note-link-span"></span>
</small>
<input name="min_depth" class="text_pole textarea_compact" type="number" min="-1" max="9999" data-i18n="[placeholder]ext_regex_min_depth_placeholder" placeholder="Unlimited" />
</div>
<div class="flex1 flex-container flexNoGap">
<small data-i18n="[title]ext_regex_max_depth_desc" title="When applied to prompts or display, only affect messages no more than N levels deep. 0 = last message, 1 = penultimate message, etc. System prompt and utility prompts are not affected. Max must be greater than Min for regex to apply.">
<span data-i18n="Max Depth">Max Depth</span>
<span class="fa-solid fa-circle-question note-link-span"></span>
</small>
<input name="max_depth" class="text_pole textarea_compact" type="number" min="0" max="9999" data-i18n="[placeholder]ext_regex_min_depth_placeholder" placeholder="Unlimited" />
</div>
</div>
</div>
<div class="flex1 wi-enter-footer-text flex-container flexFlowColumn flexNoGap alignitemsstart">
<small data-i18n="ext_regex_other_options">Other Options</small>
<label class="checkbox flex-container">
<input type="checkbox" name="disabled" />
<span data-i18n="Disabled">Disabled</span>
</label>
<label class="checkbox flex-container" data-i18n="[title]ext_regex_run_on_edit_desc" title="Run the regex script when the message belonging a to specified role(s) is edited.">
<input type="checkbox" name="run_on_edit" />
<span data-i18n="Run On Edit">Run On Edit</span>
</label>
<label class="checkbox flex-container flexNoGap marginBot5" data-i18n="[title]ext_regex_substitute_regex_desc" title="Substitute &lcub;&lcub;macros&rcub;&rcub; in Find Regex before running it">
<span>
<small data-i18n="Macro in Find Regex">Macros in Find Regex</small>
<span class="fa-solid fa-circle-question note-link-span"></span>
</span>
<select name="substitute_regex" class="text_pole textarea_compact margin0">
<option value="0" data-i18n="Don't substitute">Don't substitute</option>
<option value="1" data-i18n="Substitute (raw)">Substitute (raw)</option>
<option value="2" data-i18n="Substitute (escaped)">Substitute (escaped)</option>
</select>
</label>
<span>
<small data-i18n="Ephemerality">Ephemerality</small>
<span class="fa-solid fa-circle-question note-link-span" data-i18n="[title]ext_regex_other_options_desc" title="By default, regex scripts alter the chat file directly and irreversibly.&#13;Enabling either (or both) of the options below will prevent chat file alteration, while still altering the specified item(s)."></span>
</span>
<label class="checkbox flex-container" data-i18n="[title]ext_regex_only_format_visual_desc" title="Chat history file contents won't change, but regex will be applied to the messages displayed in the Chat UI.">
<input type="checkbox" name="only_format_display" />
<span data-i18n="Only Format Display">Alter Chat Display</span>
</label>
<label class="checkbox flex-container" data-i18n="[title]ext_regex_only_format_prompt_desc" title="Chat history file contents won't change, but regex will be applied to the outgoing prompt before it is sent to the LLM.">
<input type="checkbox" name="only_format_prompt" />
<span data-i18n="Only Format Prompt (?)">Alter Outgoing Prompt</span>
</label>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
<div>
<h3 data-i18n="This character has embedded regex script(s).">This character has embedded regex script(s).</h3>
<h3 data-i18n="Would you like to allow using them?">Would you like to allow using them?</h3>
<div class="m-b-1" data-i18n="If you want to do it later, select 'Regex' from the extensions menu.">If you want to do it later, select "Regex" from the extensions menu.</div>
</div>

View File

@@ -0,0 +1,465 @@
import { characters, saveSettingsDebounced, substituteParams, substituteParamsExtended, this_chid } from '../../../script.js';
import { extension_settings, writeExtensionField } from '../../extensions.js';
import { getPresetManager } from '../../preset-manager.js';
import { regexFromString } from '../../utils.js';
import { lodash } from '../../../lib.js';
/**
* @readonly
* @enum {number} Regex scripts types
*/
export const SCRIPT_TYPES = {
// ORDER MATTERS: defines the regex script priority
GLOBAL: 0,
PRESET: 2,
SCOPED: 1,
};
/**
* Special type for unknown/invalid script types.
*/
export const SCRIPT_TYPE_UNKNOWN = -1;
/**
* @typedef {import('../../char-data.js').RegexScriptData} RegexScript
*/
/**
* @typedef {object} GetRegexScriptsOptions
* @property {boolean} allowedOnly Only return allowed scripts
*/
/**
* @type {Readonly<GetRegexScriptsOptions>}
*/
const DEFAULT_GET_REGEX_SCRIPTS_OPTIONS = Object.freeze({ allowedOnly: false });
/**
* Manages the compiled regex cache with LRU eviction.
*/
export class RegexProvider {
/** @type {Map<string, RegExp>} */
#cache = new Map();
/** @type {number} */
#maxSize = 1000;
static instance = new RegexProvider();
/**
* Gets a regex instance by its string representation.
* @param {string} regexString The regex string to retrieve
* @returns {RegExp?} Compiled regex or null if invalid
*/
get(regexString) {
const isCached = this.#cache.has(regexString);
const regex = isCached
? this.#cache.get(regexString)
: regexFromString(regexString);
if (!regex) {
return null;
}
if (isCached) {
// LRU: Move to end by re-inserting
this.#cache.delete(regexString);
this.#cache.set(regexString, regex);
} else {
// Evict oldest if at capacity
if (this.#cache.size >= this.#maxSize) {
const firstKey = this.#cache.keys().next().value;
this.#cache.delete(firstKey);
}
this.#cache.set(regexString, regex);
}
// Reset lastIndex for global/sticky regexes
if (regex.global || regex.sticky) {
regex.lastIndex = 0;
}
return regex;
}
/**
* Clears the entire cache.
*/
clear() {
this.#cache.clear();
}
}
/**
* Retrieves the list of regex scripts by combining the scripts from the extension settings and the character data
*
* @param {GetRegexScriptsOptions} options Options for retrieving the regex scripts
* @returns {RegexScript[]} An array of regex scripts, where each script is an object containing the necessary information.
*/
export function getRegexScripts(options = DEFAULT_GET_REGEX_SCRIPTS_OPTIONS) {
return [...Object.values(SCRIPT_TYPES).flatMap(type => getScriptsByType(type, options))];
}
/**
* Retrieves the regex scripts for a specific type.
* @param {SCRIPT_TYPES} scriptType The type of regex scripts to retrieve.
* @param {GetRegexScriptsOptions} options Options for retrieving the regex scripts
* @returns {RegexScript[]} An array of regex scripts for the specified type.
*/
export function getScriptsByType(scriptType, { allowedOnly } = DEFAULT_GET_REGEX_SCRIPTS_OPTIONS) {
switch (scriptType) {
case SCRIPT_TYPE_UNKNOWN:
return [];
case SCRIPT_TYPES.GLOBAL:
return extension_settings.regex ?? [];
case SCRIPT_TYPES.SCOPED: {
if (allowedOnly && !extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar)) {
return [];
}
const scopedScripts = characters[this_chid]?.data?.extensions?.regex_scripts;
return Array.isArray(scopedScripts) ? scopedScripts : [];
}
case SCRIPT_TYPES.PRESET: {
if (allowedOnly && !extension_settings?.preset_allowed_regex?.[getCurrentPresetAPI()]?.includes(getCurrentPresetName())) {
return [];
}
const presetManager = getPresetManager();
const presetScripts = presetManager?.readPresetExtensionField({ path: 'regex_scripts' });
return Array.isArray(presetScripts) ? presetScripts : [];
}
default:
console.warn(`getScriptsByType: Invalid script type ${scriptType}`);
return [];
}
}
/**
* Saves an array of regex scripts for a specific type.
* @param {RegexScript[]} scripts An array of regex scripts to save.
* @param {SCRIPT_TYPES} scriptType The type of regex scripts to save.
* @returns {Promise<void>}
*/
export async function saveScriptsByType(scripts, scriptType) {
switch (scriptType) {
case SCRIPT_TYPES.GLOBAL:
extension_settings.regex = scripts;
saveSettingsDebounced();
break;
case SCRIPT_TYPES.SCOPED:
await writeExtensionField(this_chid, 'regex_scripts', scripts);
break;
case SCRIPT_TYPES.PRESET: {
const presetManager = getPresetManager();
await presetManager.writePresetExtensionField({ path: 'regex_scripts', value: scripts });
break;
}
default:
console.warn(`saveScriptsByType: Invalid script type ${scriptType}`);
break;
}
}
/**
* Check if character's regexes are allowed to be used; if character is undefined, returns false
* @param {Character|undefined} character
* @returns {boolean}
*/
export function isScopedScriptsAllowed(character) {
return !!extension_settings?.character_allowed_regex?.includes(character?.avatar);
}
/**
* Allow character's regexes to be used; if character is undefined, do nothing
* @param {Character|undefined} character
* @returns {void}
*/
export function allowScopedScripts(character) {
const avatar = character?.avatar;
if (!avatar) {
return;
}
if (!Array.isArray(extension_settings?.character_allowed_regex)) {
extension_settings.character_allowed_regex = [];
}
if (!extension_settings.character_allowed_regex.includes(avatar)) {
extension_settings.character_allowed_regex.push(avatar);
saveSettingsDebounced();
}
}
/**
* Disallow character's regexes to be used; if character is undefined, do nothing
* @param {Character|undefined} character
* @returns {void}
*/
export function disallowScopedScripts(character) {
const avatar = character?.avatar;
if (!avatar) {
return;
}
if (!Array.isArray(extension_settings?.character_allowed_regex)) {
return;
}
const index = extension_settings.character_allowed_regex.indexOf(avatar);
if (index !== -1) {
extension_settings.character_allowed_regex.splice(index, 1);
saveSettingsDebounced();
}
}
/**
* Check if preset's regexes are allowed to be used
* @param {string} apiId API ID
* @param {string} presetName Preset name
* @returns {boolean} True if allowed, false if not
*/
export function isPresetScriptsAllowed(apiId, presetName) {
if (!apiId || !presetName) {
return false;
}
return !!extension_settings?.preset_allowed_regex?.[apiId]?.includes(presetName);
}
/**
* Allow preset's regexes to be used
* @param {string} apiId API ID
* @param {string} presetName Preset name
* @returns {void}
*/
export function allowPresetScripts(apiId, presetName) {
if (!apiId || !presetName) {
return;
}
if (!Array.isArray(extension_settings?.preset_allowed_regex?.[apiId])) {
lodash.set(extension_settings, ['preset_allowed_regex', apiId], []);
}
if (!extension_settings.preset_allowed_regex[apiId].includes(presetName)) {
extension_settings.preset_allowed_regex[apiId].push(presetName);
saveSettingsDebounced();
}
}
/**
* Disallow preset's regexes to be used
* @param {string} apiId API ID
* @param {string} presetName Preset name
* @returns {void}
*/
export function disallowPresetScripts(apiId, presetName) {
if (!apiId || !presetName) {
return;
}
if (!Array.isArray(extension_settings?.preset_allowed_regex?.[apiId])) {
return;
}
const index = extension_settings.preset_allowed_regex[apiId].indexOf(presetName);
if (index !== -1) {
extension_settings.preset_allowed_regex[apiId].splice(index, 1);
saveSettingsDebounced();
}
}
/**
* Gets the current API ID from the preset manager.
* @returns {string|null} Current API ID, or null if no preset manager
*/
export function getCurrentPresetAPI() {
return getPresetManager()?.apiId ?? null;
}
/**
* Gets the name of the currently selected preset.
* @returns {string|null} The name of the currently selected preset, or null if no preset manager
*/
export function getCurrentPresetName() {
return getPresetManager()?.getSelectedPresetName() ?? null;
}
/**
* @readonly
* @enum {number} Where the regex script should be applied
*/
export const regex_placement = {
/**
* @deprecated MD Display is deprecated. Do not use.
*/
MD_DISPLAY: 0,
USER_INPUT: 1,
AI_OUTPUT: 2,
SLASH_COMMAND: 3,
// 4 - sendAs (legacy)
WORLD_INFO: 5,
REASONING: 6,
};
/**
* @readonly
* @enum {number} How to substitute parameters in the find regex
*/
export const substitute_find_regex = {
NONE: 0,
RAW: 1,
ESCAPED: 2,
};
function sanitizeRegexMacro(x) {
return (x && typeof x === 'string') ?
x.replaceAll(/[\n\r\t\v\f\0.^$*+?{}[\]\\/|()]/gs, function (s) {
switch (s) {
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
case '\v':
return '\\v';
case '\f':
return '\\f';
case '\0':
return '\\0';
default:
return '\\' + s;
}
}) : x;
}
/**
* Parent function to fetch a regexed version of a raw string
* @param {string} rawString The raw string to be regexed
* @param {regex_placement} placement The placement of the string
* @param {RegexParams} params The parameters to use for the regex script
* @returns {string} The regexed string
* @typedef {{characterOverride?: string, isMarkdown?: boolean, isPrompt?: boolean, isEdit?: boolean, depth?: number }} RegexParams The parameters to use for the regex script
*/
export function getRegexedString(rawString, placement, { characterOverride, isMarkdown, isPrompt, isEdit, depth } = {}) {
// WTF have you passed me?
if (typeof rawString !== 'string') {
console.warn('getRegexedString: rawString is not a string. Returning empty string.');
return '';
}
let finalString = rawString;
if (extension_settings.disabledExtensions.includes('regex') || !rawString || placement === undefined) {
return finalString;
}
const allRegex = getRegexScripts({ allowedOnly: true });
allRegex.forEach((script) => {
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 && !isPrompt)
) {
if (isEdit && !script.runOnEdit) {
console.debug(`getRegexedString: Skipping script ${script.scriptName} because it does not run on edit`);
return;
}
// Check if the depth is within the min/max depth
if (typeof depth === 'number') {
if (!isNaN(script.minDepth) && script.minDepth !== null && script.minDepth >= -1 && depth < script.minDepth) {
console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is less than minDepth ${script.minDepth}`);
return;
}
if (!isNaN(script.maxDepth) && script.maxDepth !== null && script.maxDepth >= 0 && depth > script.maxDepth) {
console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is greater than maxDepth ${script.maxDepth}`);
return;
}
}
if (script.placement.includes(placement)) {
finalString = runRegexScript(script, finalString, { characterOverride });
}
}
});
return finalString;
}
/**
* Runs the provided regex script on the given string
* @param {RegexScript} regexScript The regex script to run
* @param {string} rawString The string to run the regex script on
* @param {RegexScriptParams} params The parameters to use for the regex script
* @returns {string} The new string
* @typedef {{characterOverride?: string}} RegexScriptParams The parameters to use for the regex script
*/
export function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
let newString = rawString;
if (!regexScript || !!(regexScript.disabled) || !regexScript?.findRegex || !rawString) {
return newString;
}
const getRegexString = () => {
switch (Number(regexScript.substituteRegex)) {
case substitute_find_regex.NONE:
return regexScript.findRegex;
case substitute_find_regex.RAW:
return substituteParamsExtended(regexScript.findRegex);
case substitute_find_regex.ESCAPED:
return substituteParamsExtended(regexScript.findRegex, {}, sanitizeRegexMacro);
default:
console.warn(`runRegexScript: Unknown substituteRegex value ${regexScript.substituteRegex}. Using raw regex.`);
return regexScript.findRegex;
}
};
const regexString = getRegexString();
const findRegex = RegexProvider.instance.get(regexString);
// The user skill issued. Return with nothing.
if (!findRegex) {
return newString;
}
// Run replacement. Currently does not support the Overlay strategy
newString = rawString.replace(findRegex, function (match) {
const args = [...arguments];
const replaceString = regexScript.replaceString.replace(/{{match}}/gi, '$0');
const replaceWithGroups = replaceString.replaceAll(/\$(\d+)|\$<([^>]+)>/g, (_, num, groupName) => {
if (num) {
// Handle numbered capture groups ($1, $2, etc.)
match = args[Number(num)];
} else if (groupName) {
// Handle named capture groups ($<name>)
const groups = args[args.length - 1];
match = groups && typeof groups === 'object' && groups[groupName];
}
// No match found - return the empty string
if (!match) {
return '';
}
// Remove trim strings from the match
const filteredMatch = filterString(match, regexScript.trimStrings, { characterOverride });
return filteredMatch;
});
// Substitute at the end
return substituteParams(replaceWithGroups);
});
return newString;
}
/**
* Filters anything to trim from the regex match
* @param {string} rawString The raw string to filter
* @param {string[]} trimStrings The strings to trim
* @param {RegexScriptParams} params The parameters to use for the regex filter
* @returns {string} The filtered string
*/
function filterString(rawString, trimStrings, { characterOverride } = {}) {
let finalString = rawString;
trimStrings.forEach((trimString) => {
const subTrimString = substituteParams(trimString, { name2Override: characterOverride });
finalString = finalString.replaceAll(subTrimString, '');
});
return finalString;
}

View File

@@ -0,0 +1,25 @@
<div>
<h3 data-i18n="ext_regex_import_target">
Import To:
</h3>
<div class="flex-container flexFlowColumn wide100p padding10 justifyLeft">
<label for="regex_import_target_global">
<input type="radio" name="regex_import_target" id="regex_import_target_global" value="global" checked />
<span data-i18n="ext_regex_global_scripts">
Global Scripts
</span>
</label>
<label for="regex_import_target_preset">
<input type="radio" name="regex_import_target" id="regex_import_target_preset" value="preset" />
<span data-i18n="ext_regex_preset_scripts">
Preset Scripts
</span>
</label>
<label for="regex_import_target_scoped">
<input type="radio" name="regex_import_target" id="regex_import_target_scoped" value="scoped" />
<span data-i18n="ext_regex_scoped_scripts">
Scoped Scripts
</span>
</label>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
{
"display_name": "Regex",
"loading_order": 1,
"requires": [],
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "kingbri",
"version": "1.0.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}

View File

@@ -0,0 +1,5 @@
<div>
<h3 data-i18n="This preset has embedded regex script(s).">This preset has embedded regex script(s).</h3>
<h3 data-i18n="Would you like to allow using them?">Would you like to allow using them?</h3>
<div class="m-b-1" data-i18n="If you want to do it later, select 'Regex' from the extensions menu.">If you want to do it later, select "Regex" from the extensions menu.</div>
</div>

View File

@@ -0,0 +1,36 @@
<div class="regex-script-label flex-container flexnowrap">
<input type="checkbox" class="regex_bulk_checkbox" />
<span class="drag-handle menu-handle">&#9776;</span>
<div class="regex_script_name flex1 overflow-hidden"></div>
<div class="flex-container flexnowrap">
<label class="checkbox flex-container margin-r5" for="regex_disable">
<input type="checkbox" name="regex_disable" class="disable_regex" />
<span class="regex-toggle-on fa-solid fa-toggle-on" data-i18n="[title]ext_regex_disable_script" title="Disable script"></span>
<span class="regex-toggle-off fa-solid fa-toggle-off" data-i18n="[title]ext_regex_enable_script" title="Enable script"></span>
</label>
<label class="menu_button regex_script_expand" title="Show more options" data-i18n="[title]Show more options">
<input type="checkbox" name="regex_expand" />
<span class="fa-solid fa-ellipsis"></span>
</label>
<div class="flex-container regex_script_buttons">
<div class="move_to_global menu_button" data-i18n="[title]ext_regex_move_to_global" title="Move to global scripts">
<i class="fa-solid fa-globe"></i>
</div>
<div class="move_to_preset menu_button" data-i18n="[title]ext_regex_move_to_preset" title="Move to preset scripts">
<i class="fa-solid fa-sliders"></i>
</div>
<div class="move_to_scoped menu_button" data-i18n="[title]ext_regex_move_to_scoped" title="Move to scoped scripts">
<i class="fa-solid fa-address-card"></i>
</div>
<div class="export_regex menu_button" data-i18n="[title]ext_regex_export_script" title="Export script">
<i class="fa-solid fa-file-export"></i>
</div>
</div>
<div class="edit_existing_regex menu_button" data-i18n="[title]ext_regex_edit_script" title="Edit script">
<i class="fa-solid fa-pencil"></i>
</div>
<div class="delete_regex menu_button" data-i18n="[title]ext_regex_delete_script" title="Delete script">
<i class="fa-solid fa-trash"></i>
</div>
</div>
</div>

View File

@@ -0,0 +1,161 @@
@import "debugger.css";
.regex_settings .menu_button {
width: fit-content;
display: flex;
gap: 10px;
flex-direction: row;
}
.regex_settings .checkbox {
align-items: center;
}
.regex-script-container {
margin-top: 10px;
margin-bottom: 10px;
}
.regex-script-container:empty::after {
content: attr(no-scripts-text);
font-size: 0.95em;
opacity: 0.7;
display: block;
text-align: center;
}
#scoped_scripts_block,
#preset_scripts_block {
opacity: 1;
transition: opacity var(--animation-duration-2x) ease-in-out;
}
#scoped_scripts_block .move_to_scoped,
#global_scripts_block .move_to_global,
#preset_scripts_block .move_to_preset {
display: none;
}
#scoped_scripts_block:not(:has(#regex_scoped_toggle:checked)),
#preset_scripts_block:not(:has(#regex_preset_toggle:checked)) {
opacity: 0.5;
}
.enable_scoped:checked~.regex-toggle-on,
.enable_scoped:not(:checked)~.regex-toggle-off {
display: block;
}
.enable_scoped:checked~.regex-toggle-off,
.enable_scoped:not(:checked)~.regex-toggle-on {
display: none;
}
.regex-script-label {
align-items: baseline;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 10px;
padding: 0 5px;
margin-top: 1px;
margin-bottom: 1px;
}
.regex-script-label:has(.disable_regex:checked) .regex_script_name {
text-decoration: line-through;
filter: grayscale(0.5);
}
input.disable_regex,
input.enable_scoped {
display: none !important;
}
.regex-toggle-off {
cursor: pointer;
opacity: 0.5;
filter: grayscale(0.5);
transition: opacity var(--animation-duration-2x) ease-in-out;
}
.regex-toggle-off:hover {
opacity: 1;
filter: none;
}
.regex-toggle-on {
cursor: pointer;
}
.disable_regex:checked~.regex-toggle-on,
.disable_regex:not(:checked)~.regex-toggle-off {
display: none;
}
.disable_regex:not(:checked)~.regex-toggle-on,
.disable_regex:checked~.regex-toggle-off {
display: block;
}
#regex_info_block_wrapper {
position: relative;
}
#regex_info_block {
margin: 10px 0;
padding: 5px 20px;
font-size: 0.9em;
}
#regex_info_block_wrapper:has(#regex_info_block:empty) {
display: none;
}
#regex_info_block_flags_hint {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
}
.regex_settings label[for="regex_bulk_edit"]:has(#regex_bulk_edit:checked) {
color: var(--golden);
}
.regex_settings .regex-script-container .regex-script-label .regex_bulk_checkbox {
margin-left: 5px;
margin-right: 5px;
}
.regex_settings .regex_bulk_operations,
.regex_settings .regex_bulk_checkbox,
.regex_settings .regex_bulk_operations_hr {
display: none;
}
.regex_settings:has(#regex_bulk_edit:checked) .regex_bulk_operations {
display: flex;
}
.regex_settings:has(#regex_bulk_edit:checked) .regex_bulk_operations_hr {
display: block;
}
.regex_settings:has(#regex_bulk_edit:checked) .regex_bulk_checkbox {
display: inline-grid;
}
@supports not selector(:has(*)) {
.regex-script-label label.regex_script_expand {
display: none;
}
.regex-script-label .regex_script_buttons {
display: flex;
}
}
.regex-script-label label.regex_script_expand input[name="regex_expand"],
.regex-script-label:has(input[name="regex_expand"]:checked) label.regex_script_expand,
.regex-script-label:not(:has(input[name="regex_expand"]:checked)) .regex_script_buttons {
display: none;
}

View File

@@ -0,0 +1,733 @@
import { CONNECT_API_MAP, getRequestHeaders } from '../../script.js';
import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';
import { t } from '../i18n.js';
import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js';
import { SECRET_KEYS, secret_state } from '../secrets.js';
import { textgen_types, textgenerationwebui_settings } from '../textgen-settings.js';
import { getTokenCountAsync } from '../tokenizers.js';
import { createThumbnail, isValidUrl } 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) {
const useReverseProxy =
(['openai', 'anthropic', 'google', 'mistral', 'vertexai', 'xai'].includes(extension_settings.caption.multimodal_api))
&& extension_settings.caption.allow_reverse_proxy
&& oai_settings.reverse_proxy
&& isValidUrl(oai_settings.reverse_proxy);
throwIfInvalidModel(useReverseProxy);
// OpenRouter has a payload limit of ~2MB. Google is 4MB, but we love democracy.
// Ooba requires all images to be JPEGs. Koboldcpp just asked nicely.
const isOllama = extension_settings.caption.multimodal_api === 'ollama';
const isLlamaCpp = extension_settings.caption.multimodal_api === 'llamacpp';
const isCustom = extension_settings.caption.multimodal_api === 'custom';
const isOoba = extension_settings.caption.multimodal_api === 'ooba';
const isKoboldCpp = extension_settings.caption.multimodal_api === 'koboldcpp';
const isVllm = extension_settings.caption.multimodal_api === 'vllm';
const base64Bytes = base64Img.length * 0.75;
const compressionLimit = 2 * 1024 * 1024;
const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
const mimeType = base64Img?.split(';')?.[0]?.split(':')?.[1] || 'image/jpeg';
const isImage = mimeType.startsWith('image/');
const thumbnailNeeded = ['google', 'openrouter', 'mistral', 'groq', 'vertexai'].includes(extension_settings.caption.multimodal_api);
if ((isImage && thumbnailNeeded && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) {
const maxSide = 2048;
base64Img = await createThumbnail(base64Img, maxSide, maxSide);
} else if (isImage && !safeMimeTypes.includes(mimeType)) {
base64Img = await createThumbnail(base64Img, null, null);
}
if (isOllama && base64Img.startsWith('data:image/')) {
base64Img = base64Img.split(',')[1];
}
const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : '';
const proxyPassword = useReverseProxy ? oai_settings.proxy_password : '';
const requestBody = {
image: base64Img,
prompt: prompt,
reverse_proxy: proxyUrl,
proxy_password: proxyPassword,
api: extension_settings.caption.multimodal_api || 'openai',
model: extension_settings.caption.multimodal_model || 'gpt-4-turbo',
};
// Add Vertex AI specific parameters if using Vertex AI
if (extension_settings.caption.multimodal_api === 'vertexai') {
requestBody.vertexai_auth_mode = oai_settings.vertexai_auth_mode;
requestBody.vertexai_region = oai_settings.vertexai_region;
requestBody.vertexai_express_project_id = oai_settings.vertexai_express_project_id;
}
if (isOllama) {
if (extension_settings.caption.multimodal_model === 'ollama_current') {
requestBody.model = textgenerationwebui_settings.ollama_model;
}
if (extension_settings.caption.multimodal_model === 'ollama_custom') {
requestBody.model = extension_settings.caption.ollama_custom_model;
}
requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
? extension_settings.caption.alt_endpoint_url
: textgenerationwebui_settings.server_urls[textgen_types.OLLAMA];
}
if (isVllm) {
if (extension_settings.caption.multimodal_model === 'vllm_current') {
requestBody.model = textgenerationwebui_settings.vllm_model;
}
requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
? extension_settings.caption.alt_endpoint_url
: textgenerationwebui_settings.server_urls[textgen_types.VLLM];
}
if (isLlamaCpp) {
requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
? extension_settings.caption.alt_endpoint_url
: textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP];
}
if (isOoba) {
requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
? extension_settings.caption.alt_endpoint_url
: textgenerationwebui_settings.server_urls[textgen_types.OOBA];
}
if (isKoboldCpp) {
requestBody.server_url = extension_settings.caption.alt_endpoint_enabled
? extension_settings.caption.alt_endpoint_url
: textgenerationwebui_settings.server_urls[textgen_types.KOBOLDCPP];
}
if (isCustom) {
requestBody.server_url = oai_settings.custom_url;
requestBody.model = oai_settings.custom_model || 'gpt-4-turbo';
requestBody.custom_include_headers = oai_settings.custom_include_headers;
requestBody.custom_include_body = oai_settings.custom_include_body;
requestBody.custom_exclude_body = oai_settings.custom_exclude_body;
}
if (extension_settings.caption.multimodal_api === 'zai') {
requestBody.zai_endpoint = oai_settings.zai_endpoint || ZAI_ENDPOINT.COMMON;
}
function getEndpointUrl() {
switch (extension_settings.caption.multimodal_api) {
case 'google':
case 'vertexai':
return '/api/google/caption-image';
case 'anthropic':
return '/api/anthropic/caption-image';
case 'ollama':
return '/api/backends/text-completions/ollama/caption-image';
default:
return '/api/openai/caption-image';
}
}
const apiResult = await fetch(getEndpointUrl(), {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(requestBody),
});
if (!apiResult.ok) {
throw new Error('Failed to caption image via Multimodal API.');
}
const { caption } = await apiResult.json();
return String(caption).trim();
}
function throwIfInvalidModel(useReverseProxy) {
const altEndpointEnabled = extension_settings.caption.alt_endpoint_enabled;
const altEndpointUrl = extension_settings.caption.alt_endpoint_url;
const multimodalModel = extension_settings.caption.multimodal_model;
const multimodalApi = extension_settings.caption.multimodal_api;
if (altEndpointEnabled && ['llamacpp', 'ooba', 'koboldcpp', 'vllm', 'ollama'].includes(multimodalApi) && !altEndpointUrl) {
throw new Error('Secondary endpoint URL is not set.');
}
if (multimodalApi === 'openai' && !secret_state[SECRET_KEYS.OPENAI] && !useReverseProxy) {
throw new Error('OpenAI API key is not set.');
}
if (multimodalApi === 'openrouter' && !secret_state[SECRET_KEYS.OPENROUTER]) {
throw new Error('OpenRouter API key is not set.');
}
if (multimodalApi === 'anthropic' && !secret_state[SECRET_KEYS.CLAUDE] && !useReverseProxy) {
throw new Error('Anthropic (Claude) API key is not set.');
}
if (multimodalApi === 'groq' && !secret_state[SECRET_KEYS.GROQ]) {
throw new Error('Groq API key is not set.');
}
if (multimodalApi === 'google' && !secret_state[SECRET_KEYS.MAKERSUITE] && !useReverseProxy) {
throw new Error('Google AI Studio API key is not set.');
}
if (multimodalApi === 'vertexai' && !useReverseProxy) {
// Check based on authentication mode
const authMode = oai_settings.vertexai_auth_mode || 'express';
if (authMode === 'express') {
// Express mode requires API key
if (!secret_state[SECRET_KEYS.VERTEXAI]) {
throw new Error('Google Vertex AI API key is not set for Express mode.');
}
} else if (authMode === 'full') {
// Full mode requires Service Account JSON and region settings
if (!secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]) {
throw new Error('Service Account JSON is required for Vertex AI Full mode. Please validate and save your Service Account JSON.');
}
if (!oai_settings.vertexai_region) {
throw new Error('Region is required for Vertex AI Full mode.');
}
}
}
if (multimodalApi === 'mistral' && !secret_state[SECRET_KEYS.MISTRALAI] && !useReverseProxy) {
throw new Error('Mistral AI API key is not set.');
}
if (multimodalApi === 'cohere' && !secret_state[SECRET_KEYS.COHERE]) {
throw new Error('Cohere API key is not set.');
}
if (multimodalApi === 'xai' && !secret_state[SECRET_KEYS.XAI] && !useReverseProxy) {
throw new Error('xAI API key is not set.');
}
if (multimodalApi === 'ollama' && !textgenerationwebui_settings.server_urls[textgen_types.OLLAMA] && !altEndpointEnabled) {
throw new Error('Ollama server URL is not set.');
}
if (multimodalApi === 'ollama' && multimodalModel === 'ollama_current' && !textgenerationwebui_settings.ollama_model) {
throw new Error('Ollama model is not set.');
}
if (multimodalApi === 'ollama' && multimodalModel === 'ollama_custom' && !extension_settings.caption.ollama_custom_model) {
throw new Error('Ollama custom model tag is not set.');
}
if (multimodalApi === 'llamacpp' && !textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP] && !altEndpointEnabled) {
throw new Error('LlamaCPP server URL is not set.');
}
if (multimodalApi === 'ooba' && !textgenerationwebui_settings.server_urls[textgen_types.OOBA] && !altEndpointEnabled) {
throw new Error('Text Generation WebUI server URL is not set.');
}
if (multimodalApi === 'koboldcpp' && !textgenerationwebui_settings.server_urls[textgen_types.KOBOLDCPP] && !altEndpointEnabled) {
throw new Error('KoboldCpp server URL is not set.');
}
if (multimodalApi === 'vllm' && !textgenerationwebui_settings.server_urls[textgen_types.VLLM] && !altEndpointEnabled) {
throw new Error('vLLM server URL is not set.');
}
if (multimodalApi === 'vllm' && multimodalModel === 'vllm_current' && !textgenerationwebui_settings.vllm_model) {
throw new Error('vLLM model is not set.');
}
if (multimodalApi === 'custom' && !oai_settings.custom_url) {
throw new Error('Custom API URL is not set.');
}
if (multimodalApi === 'aimlapi' && !secret_state[SECRET_KEYS.AIMLAPI]) {
throw new Error('AI/ML API key is not set.');
}
if (multimodalApi === 'moonshot' && !secret_state[SECRET_KEYS.MOONSHOT]) {
throw new Error('Moonshot AI API key is not set.');
}
if (multimodalApi === 'nanogpt' && !secret_state[SECRET_KEYS.NANOGPT]) {
throw new Error('NanoGPT API key is not set.');
}
if (multimodalApi === 'electronhub' && !secret_state[SECRET_KEYS.ELECTRONHUB]) {
throw new Error('Electron Hub API key is not set.');
}
if (multimodalApi === 'chutes' && !secret_state[SECRET_KEYS.CHUTES]) {
throw new Error('Chutes API key is not set.');
}
if (multimodalApi === 'zai' && !secret_state[SECRET_KEYS.ZAI]) {
throw new Error('Z.AI API key is not set.');
}
}
/**
* Check if the WebLLM extension is installed and supported.
* @returns {boolean} Whether the extension is installed and supported
*/
export function isWebLlmSupported() {
if (!('gpu' in navigator)) {
const warningKey = 'webllm_browser_warning_shown';
if (!sessionStorage.getItem(warningKey)) {
toastr.error('Your browser does not support the WebGPU API. Please use a different browser.', 'WebLLM', {
preventDuplicates: true,
timeOut: 0,
extendedTimeOut: 0,
});
sessionStorage.setItem(warningKey, '1');
}
return false;
}
if (!('llm' in SillyTavern)) {
const warningKey = 'webllm_extension_warning_shown';
if (!sessionStorage.getItem(warningKey)) {
toastr.error('WebLLM extension is not installed. Click here to install it.', 'WebLLM', {
timeOut: 0,
extendedTimeOut: 0,
preventDuplicates: true,
onclick: () => openThirdPartyExtensionMenu('https://github.com/SillyTavern/Extension-WebLLM'),
});
sessionStorage.setItem(warningKey, '1');
}
return false;
}
return true;
}
/**
* Generates text in response to a chat prompt using WebLLM.
* @param {any[]} messages Messages to use for generating
* @param {object} params Additional parameters
* @returns {Promise<string>} Generated response
*/
export async function generateWebLlmChatPrompt(messages, params = {}) {
if (!isWebLlmSupported()) {
throw new Error('WebLLM extension is not installed.');
}
console.debug('WebLLM chat completion request:', messages, params);
const engine = SillyTavern.llm;
const response = await engine.generateChatPrompt(messages, params);
console.debug('WebLLM chat completion response:', response);
return response;
}
/**
* Counts the number of tokens in the provided text using WebLLM's default model.
* Fallbacks to the current model's tokenizer if WebLLM token count fails.
* @param {string} text Text to count tokens in
* @returns {Promise<number>} Number of tokens in the text
*/
export async function countWebLlmTokens(text) {
if (!isWebLlmSupported()) {
throw new Error('WebLLM extension is not installed.');
}
try {
const engine = SillyTavern.llm;
const response = await engine.countTokens(text);
return response;
} catch (error) {
// Fallback to using current model's tokenizer
return await getTokenCountAsync(text);
}
}
/**
* Gets the size of the context in the WebLLM's default model.
* @returns {Promise<number>} Size of the context in the WebLLM model
*/
export async function getWebLlmContextSize() {
if (!isWebLlmSupported()) {
throw new Error('WebLLM extension is not installed.');
}
const engine = SillyTavern.llm;
await engine.loadModel();
const model = await engine.getCurrentModelInfo();
return model?.context_size;
}
/**
* It uses the profiles to send a generate request to the API.
*/
export class ConnectionManagerRequestService {
static defaultSendRequestParams = {
stream: false,
signal: null,
extractData: true,
includePreset: true,
includeInstruct: true,
instructSettings: {},
};
static getAllowedTypes() {
return {
openai: t`Chat Completion`,
textgenerationwebui: t`Text Completion`,
};
}
/**
* @param {string} profileId
* @param {string | (import('../custom-request.js').ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt
* @param {number} maxTokens
* @param {Object} custom
* @param {boolean?} [custom.stream=false]
* @param {AbortSignal?} [custom.signal]
* @param {boolean?} [custom.extractData=true]
* @param {boolean?} [custom.includePreset=true]
* @param {boolean?} [custom.includeInstruct=true]
* @param {Partial<InstructSettings>?} [custom.instructSettings] Override instruct settings
* @param {Record<string, any>} [overridePayload] - Override payload for the request
* @returns {Promise<import('../custom-request.js').ExtractedData | (() => AsyncGenerator<import('../custom-request.js').StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
*/
static async sendRequest(profileId, prompt, maxTokens, custom = this.defaultSendRequestParams, overridePayload = {}) {
const { stream, signal, extractData, includePreset, includeInstruct, instructSettings } = { ...this.defaultSendRequestParams, ...custom };
const context = SillyTavern.getContext();
if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
throw new Error('Connection Manager is not available');
}
const profile = this.getProfile(profileId);
const selectedApiMap = this.validateProfile(profile);
try {
switch (selectedApiMap.selected) {
case 'openai': {
if (!selectedApiMap.source) {
throw new Error(`API type ${selectedApiMap.selected} does not support chat completions`);
}
const proxyPreset = proxies.find((p) => p.name === profile.proxy);
const messages = Array.isArray(prompt) ? prompt : [{ role: 'user', content: prompt }];
return await context.ChatCompletionService.processRequest({
stream,
messages,
max_tokens: maxTokens,
model: profile.model,
chat_completion_source: selectedApiMap.source,
custom_url: profile['api-url'],
vertexai_region: profile['api-url'],
zai_endpoint: profile['api-url'],
reverse_proxy: proxyPreset?.url,
proxy_password: proxyPreset?.password,
custom_prompt_post_processing: profile['prompt-post-processing'],
...overridePayload,
}, {
presetName: includePreset ? profile.preset : undefined,
}, extractData, signal);
}
case 'textgenerationwebui': {
if (!selectedApiMap.type) {
throw new Error(`API type ${selectedApiMap.selected} does not support text completions`);
}
return await context.TextCompletionService.processRequest({
stream,
prompt,
max_tokens: maxTokens,
model: profile.model,
api_type: selectedApiMap.type,
api_server: profile['api-url'],
...overridePayload,
}, {
instructName: includeInstruct ? profile.instruct : undefined,
presetName: includePreset ? profile.preset : undefined,
instructSettings: includeInstruct ? instructSettings : undefined,
}, extractData, signal);
}
default: {
throw new Error(`Unknown API type ${selectedApiMap.selected}`);
}
}
} catch (error) {
throw new Error('API request failed', { cause: error });
}
}
/**
* If using text completion, return a formatted prompt string given an array of messages, a given profile ID, and optional instruct settings.
* If using chat completion, simply return the given prompt as-is.
* @param {ChatCompletionMessage[]} prompt An array of prompt messages.
* @param {string} profileId ID of a given connection profile (from which to infer a completion preset).
* @param {InstructSettings} instructSettings optional instruct settings
*/
static constructPrompt(prompt, profileId, instructSettings = null) {
const context = SillyTavern.getContext();
const profile = this.getProfile(profileId);
const selectedApiMap = this.validateProfile(profile);
const instructName = profile.instruct;
switch (selectedApiMap.selected) {
case 'openai': {
if (!selectedApiMap.source) {
throw new Error(`API type ${selectedApiMap.selected} does not support chat completions`);
}
return prompt;
}
case 'textgenerationwebui': {
if (!selectedApiMap.type) {
throw new Error(`API type ${selectedApiMap.selected} does not support text completions`);
}
return context.TextCompletionService.constructPrompt(prompt, instructName, instructSettings);
}
default: {
throw new Error(`Unknown API type ${selectedApiMap.selected}`);
}
}
}
/**
* Respects allowed types.
* @returns {import('./connection-manager/index.js').ConnectionProfile[]}
*/
static getSupportedProfiles() {
const context = SillyTavern.getContext();
if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
throw new Error('Connection Manager is not available');
}
const profiles = context.extensionSettings.connectionManager.profiles;
return profiles.filter((p) => this.isProfileSupported(p));
}
/**
* Return profile data given the profile ID
* @param {string} profileId
* @returns {import('./connection-manager/index.js').ConnectionProfile?} [profile]
* @throws {Error}
*/
static getProfile(profileId) {
const profile = SillyTavern.getContext().extensionSettings.connectionManager.profiles.find((p) => p.id === profileId);
if (!profile) throw new Error(`Profile not found (ID: ${profileId})`);
return profile;
}
/**
* @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
* @returns {boolean}
*/
static isProfileSupported(profile) {
if (!profile || !profile.api) {
return false;
}
const apiMap = CONNECT_API_MAP[profile.api];
if (!Object.hasOwn(this.getAllowedTypes(), apiMap.selected)) {
return false;
}
// Some providers not need model, like koboldcpp. But I don't want to check by provider.
switch (apiMap.selected) {
case 'openai':
return !!apiMap.source;
case 'textgenerationwebui':
return !!apiMap.type;
}
return false;
}
/**
* @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
* @return {import('../slash-commands.js').ConnectAPIMap}
* @throws {Error}
*/
static validateProfile(profile) {
if (!profile) {
throw new Error('Could not find profile.');
}
if (!profile.api) {
throw new Error('Select a connection profile that has an API');
}
const context = SillyTavern.getContext();
const selectedApiMap = context.CONNECT_API_MAP[profile.api];
if (!selectedApiMap) {
throw new Error(`Unknown API type ${profile.api}`);
}
if (!Object.hasOwn(this.getAllowedTypes(), selectedApiMap.selected)) {
throw new Error(`API type ${selectedApiMap.selected} is not supported. Supported types: ${Object.values(this.getAllowedTypes()).join(', ')}`);
}
return selectedApiMap;
}
/**
* Create profiles dropdown and updates select element accordingly. Use onChange, onCreate, unUpdate, onDelete callbacks for custom behaviour. e.g updating extension settings.
* @param {string} selector
* @param {string} initialSelectedProfileId
* @param {(profile?: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onChange - 3 cases. 1- When user selects new profile. 2- When user deletes selected profile. 3- When user updates selected profile.
* @param {(profile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onCreate
* @param {(oldProfile: import('./connection-manager/index.js').ConnectionProfile, newProfile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} unUpdate
* @param {(profile: import('./connection-manager/index.js').ConnectionProfile) => Promise<void> | void} onDelete
*/
static handleDropdown(
selector,
initialSelectedProfileId,
onChange = () => { },
onCreate = () => { },
unUpdate = () => { },
onDelete = () => { },
) {
const context = SillyTavern.getContext();
if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
throw new Error('Connection Manager is not available');
}
/**
* @type {JQuery<HTMLSelectElement>}
*/
const dropdown = $(selector);
if (!dropdown || !dropdown.length) {
throw new Error(`Could not find dropdown with selector ${selector}`);
}
dropdown.empty();
// Create default option using document.createElement
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = 'Select a Connection Profile';
defaultOption.dataset.i18n = 'Select a Connection Profile';
dropdown.append(defaultOption);
const profiles = context.extensionSettings.connectionManager.profiles;
// Create optgroups using document.createElement
const groups = {};
for (const [apiType, groupLabel] of Object.entries(this.getAllowedTypes())) {
const optgroup = document.createElement('optgroup');
optgroup.label = groupLabel;
groups[apiType] = optgroup;
}
const sortedProfilesByGroup = {};
for (const apiType of Object.keys(this.getAllowedTypes())) {
sortedProfilesByGroup[apiType] = [];
}
for (const profile of profiles) {
if (this.isProfileSupported(profile)) {
const apiMap = CONNECT_API_MAP[profile.api];
if (sortedProfilesByGroup[apiMap.selected]) {
sortedProfilesByGroup[apiMap.selected].push(profile);
}
}
}
// Sort each group alphabetically and add to dropdown
for (const [apiType, groupProfiles] of Object.entries(sortedProfilesByGroup)) {
if (groupProfiles.length === 0) continue;
groupProfiles.sort((a, b) => a.name.localeCompare(b.name));
const group = groups[apiType];
for (const profile of groupProfiles) {
const option = document.createElement('option');
option.value = profile.id;
option.textContent = profile.name;
group.appendChild(option);
}
}
for (const group of Object.values(groups)) {
if (group.children.length > 0) {
dropdown.append(group);
}
}
const selectedProfile = profiles.find((p) => p.id === initialSelectedProfileId);
if (selectedProfile) {
dropdown.val(selectedProfile.id);
}
context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_CREATED, async (profile) => {
const isSupported = this.isProfileSupported(profile);
if (!isSupported) {
return;
}
const group = groups[CONNECT_API_MAP[profile.api].selected];
const option = document.createElement('option');
option.value = profile.id;
option.textContent = profile.name;
group.appendChild(option);
await onCreate(profile);
});
context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_UPDATED, async (oldProfile, newProfile) => {
const currentSelected = dropdown.val();
const isSelectedProfile = currentSelected === oldProfile.id;
await unUpdate(oldProfile, newProfile);
if (!this.isProfileSupported(newProfile)) {
if (isSelectedProfile) {
dropdown.val('');
dropdown.trigger('change');
}
return;
}
const group = groups[CONNECT_API_MAP[newProfile.api].selected];
const oldOption = group.querySelector(`option[value="${oldProfile.id}"]`);
if (oldOption) {
oldOption.remove();
}
const option = document.createElement('option');
option.value = newProfile.id;
option.textContent = newProfile.name;
group.appendChild(option);
if (isSelectedProfile) {
// Ackchyually, we don't need to reselect but what if id changes? It is not possible for now I couldn't stop myself.
dropdown.val(newProfile.id);
dropdown.trigger('change');
}
});
context.eventSource.on(context.eventTypes.CONNECTION_PROFILE_DELETED, async (profile) => {
const currentSelected = dropdown.val();
const isSelectedProfile = currentSelected === profile.id;
if (!this.isProfileSupported(profile)) {
return;
}
const group = groups[CONNECT_API_MAP[profile.api].selected];
const optionToRemove = group.querySelector(`option[value="${profile.id}"]`);
if (optionToRemove) {
optionToRemove.remove();
}
if (isSelectedProfile) {
dropdown.val('');
dropdown.trigger('change');
}
await onDelete(profile);
});
dropdown.on('change', async () => {
const profileId = dropdown.val();
const profile = context.extensionSettings.connectionManager.profiles.find((p) => p.id === profileId);
await onChange(profile);
});
}
}

View File

@@ -0,0 +1,8 @@
<div id="sd_gen" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-paintbrush extensionsMenuExtensionButton" title="Trigger Stable Diffusion" data-i18n="[title]Trigger Stable Diffusion"></div>
<span data-i18n="Generate Image">Generate Image</span>
</div>
<div id="sd_stop_gen" class="list-group-item flex-container flexGap5">
<div class="fa-solid fa-circle-stop extensionsMenuExtensionButton" title="Abort current image generation task" data-i18n="[title]Abort current image generation task"></div>
<span data-i18n="Stop Image Generation">Stop Image Generation</span>
</div>

View File

@@ -0,0 +1,41 @@
<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="Insert your ComfyUI workflow here by copying the JSON data obtained via the 'Save (API Format)' option. This option becomes available after enabling 'Dev Mode' in the settings. Remember to replace specific values within your workflow with placeholders as required for your use case."></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="denoise" class="sd_comfy_workflow_editor_not_found">"%denoise%"</li>
<li data-placeholder="clip_skip" class="sd_comfy_workflow_editor_not_found">"%clip_skip%"</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 data-placeholder="user_avatar" class="sd_comfy_workflow_editor_not_found">"%user_avatar%"</li>
<li data-placeholder="char_avatar" class="sd_comfy_workflow_editor_not_found">"%char_avatar%"</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>Custom</div>
<div class="sd_comfy_workflow_editor_placeholder_actions">
<span id="sd_comfy_workflow_editor_placeholder_add" title="Add custom placeholder">+</span>
</div>
<ul class="sd_comfy_workflow_editor_placeholder_list" id="sd_comfy_workflow_editor_placeholder_list_custom">
</ul>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,12 @@
<div id="sd_dropdown">
<ul class="list-group">
<span data-i18n="Send me a picture of:">Send me a picture of:</span>
<li class="list-group-item" id="sd_you" data-value="you" data-i18n="sd_Yourself">Yourself</li>
<li class="list-group-item" id="sd_face" data-value="face" data-i18n="sd_Your_Face">Your Face</li>
<li class="list-group-item" id="sd_me" data-value="me" data-i18n="sd_Me">Me</li>
<li class="list-group-item" id="sd_world" data-value="world" data-i18n="sd_The_Whole_Story">The Whole Story</li>
<li class="list-group-item" id="sd_last" data-value="last" data-i18n="sd_The_Last_Message">The Last Message</li>
<li class="list-group-item" id="sd_raw_last" data-value="raw_last" data-i18n="sd_Raw_Last_Message">Raw Last Message</li>
<li class="list-group-item" id="sd_background" data-value="background" data-i18n="sd_Background">Background</li>
</ul>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"display_name": "Image Generation",
"loading_order": 10,
"requires": [],
"optional": [
"sd"
],
"generate_interceptor": "SD_ProcessTriggers",
"js": "index.js",
"css": "style.css",
"author": "Cohee#1207",
"version": "1.0.0",
"homePage": "https://github.com/SillyTavern/SillyTavern"
}

View File

@@ -0,0 +1,615 @@
<div class="sd_settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b>
<span data-i18n="Image Generation">Image Generation</span>
<a href="https://docs.sillytavern.app/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">
<label for="sd_refine_mode" class="checkbox_label" data-i18n="[title]sd_refine_mode" title="Allow to edit prompts manually before sending them to generation API">
<input id="sd_refine_mode" type="checkbox" />
<span data-i18n="sd_refine_mode_txt">Edit prompts before generation</span>
</label>
<label for="sd_function_tool" class="checkbox_label" data-i18n="[title]sd_function_tool" title="Use the function tool to automatically detect intents to generate images.">
<input id="sd_function_tool" type="checkbox" />
<span data-i18n="sd_function_tool_txt">Use function tool</span>
</label>
<label for="sd_interactive_mode" class="checkbox_label" data-i18n="[title]sd_interactive_mode" title="Automatically generate images when sending messages like 'send me a picture of cat'.">
<input id="sd_interactive_mode" type="checkbox" />
<span data-i18n="sd_interactive_mode_txt">Use interactive mode</span>
</label>
<label for="sd_multimodal_captioning" class="checkbox_label" data-i18n="[title]sd_multimodal_captioning" title="Use multimodal captioning to generate prompts for user and character portraits based on their avatars.">
<input id="sd_multimodal_captioning" type="checkbox" />
<span data-i18n="sd_multimodal_captioning_txt">Use multimodal captioning for portraits</span>
</label>
<label for="sd_free_extend" class="checkbox_label" data-i18n="[title]sd_free_extend" title="Automatically extend free mode subject prompts (not portraits or backgrounds) using a currently selected LLM.">
<input id="sd_free_extend" type="checkbox" />
<span data-i18n="sd_free_extend_txt">Extend free mode prompts</span>
<small data-i18n="sd_free_extend_small">(interactive/commands)</small>
</label>
<label for="sd_snap" class="checkbox_label" data-i18n="[title]sd_snap" title="Snap generation requests with a forced aspect ratio (portraits, backgrounds) to the nearest known resolution, while trying to preserve the absolute pixel counts (recommended for SDXL).">
<input id="sd_snap" type="checkbox" />
<span data-i18n="sd_snap_txt">Snap auto-adjusted resolutions</span>
</label>
<label for="sd_source" data-i18n="Source">Source</label>
<select id="sd_source">
<option value="aimlapi">AI/ML API</option>
<option value="bfl">BFL (Black Forest Labs)</option>
<option value="chutes">Chutes</option>
<option value="comfy">ComfyUI</option>
<option value="drawthings">DrawThings HTTP API</option>
<option value="electronhub">Electron Hub</option>
<option value="extras">Extras API (deprecated)</option>
<option value="falai">FAL.AI</option>
<option value="google">Google AI</option>
<option value="huggingface">HuggingFace Inference API (serverless)</option>
<option value="nanogpt">NanoGPT</option>
<option value="novel">NovelAI Diffusion</option>
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
<option value="pollinations">Pollinations</option>
<option value="vlad">SD.Next (vladmandic)</option>
<option value="stability">Stability AI</option>
<option value="auto">Stable Diffusion Web UI (AUTOMATIC1111)</option>
<option value="horde">Stable Horde</option>
<option value="togetherai">TogetherAI</option>
<option value="xai">xAI (Grok)</option>
<option value="zai">Z.AI (CogView)</option>
</select>
<div data-sd-source="auto">
<label for="sd_auto_url">SD Web UI URL</label>
<div class="flex-container flexnowrap">
<input id="sd_auto_url" type="text" class="text_pole" data-i18n="[placeholder]sd_auto_url" placeholder="Example: {{auto_url}}" value="{{auto_url}}" />
<div id="sd_auto_validate" class="menu_button menu_button_icon">
<i class="fa-solid fa-check"></i>
<span data-i18n="Connect">
Connect
</span>
</div>
</div>
<label for="sd_auto_auth" data-i18n="Authentication (optional)">Authentication (optional)</label>
<input id="sd_auto_auth" type="text" class="text_pole" data-i18n="[placeholder]Example: username:password" placeholder="Example: username:password" value="" />
<!-- (Original Text)<b>Important:</b> run SD Web UI with the <tt>--api</tt> flag! The server must be accessible from the SillyTavern host machine. -->
<i><b data-i18n="Important:">Important:</b></i><i data-i18n="sd_auto_auth_warning_1"> run SD Web UI with the </i><i><tt>--api</tt></i><i data-i18n="sd_auto_auth_warning_2"> flag! The server must be accessible from the SillyTavern host machine.</i>
</div>
<div data-sd-source="drawthings">
<label for="sd_drawthings_url">DrawThings API URL</label>
<div class="flex-container flexnowrap">
<input id="sd_drawthings_url" type="text" class="text_pole" data-i18n="[placeholder]sd_drawthings_url" placeholder="Example: {{drawthings_url}}" value="{{drawthings_url}}" />
<div id="sd_drawthings_validate" class="menu_button menu_button_icon">
<i class="fa-solid fa-check"></i>
<span data-i18n="Connect">
Connect
</span>
</div>
</div>
<label for="sd_drawthings_auth" data-i18n="Authentication (optional)">Authentication (optional)</label>
<input id="sd_drawthings_auth" type="text" class="text_pole" data-i18n="[placeholder]Example: username:password" placeholder="Example: username:password" value="" />
<!-- (Original Text)<b>Important:</b> run DrawThings app with HTTP API switch enabled in the UI! The server must be accessible from the SillyTavern host machine. -->
<i><b data-i18n="Important:">Important:</b></i><i data-i18n="sd_drawthings_auth_txt"> run DrawThings app with HTTP API switch enabled in the UI! The server must be accessible from the SillyTavern host machine.</i>
</div>
<div data-sd-source="huggingface">
<i data-i18n="Hint: Save an API key in the Hugging Face (Text Completion) API settings to use it here.">Hint: Save an API key in the Hugging Face (Text Completion) API settings to use it here.</i>
<label for="sd_huggingface_model_id" data-i18n="Model ID">Model ID</label>
<input id="sd_huggingface_model_id" type="text" class="text_pole" data-i18n="[placeholder]e.g. black-forest-labs/FLUX.1-dev" placeholder="e.g. black-forest-labs/FLUX.1-dev" value="" />
</div>
<div data-sd-source="chutes">
<i data-i18n="Hint: Save an API key in the Chutes (Chat Completion) API settings to use it here.">Hint: Save an API key in the Chutes (Chat Completion) API settings to use it here.</i>
</div>
<div data-sd-source="electronhub">
<i data-i18n="Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.">Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.</i>
<div class="flex-container" id="sd_electronhub_quality_row">
<div class="flex1">
<label for="sd_electronhub_quality" data-i18n="Image Quality">Image Quality</label>
<select id="sd_electronhub_quality"></select>
</div>
</div>
</div>
<div data-sd-source="nanogpt">
<i data-i18n="Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.">Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.</i>
</div>
<div data-sd-source="vlad">
<label for="sd_vlad_url">SD.Next API URL</label>
<div class="flex-container flexnowrap">
<input id="sd_vlad_url" type="text" class="text_pole" data-i18n="[placeholder]sd_vlad_url" placeholder="Example: {{vlad_url}}" value="{{vlad_url}}" />
<div id="sd_vlad_validate" class="menu_button menu_button_icon">
<i class="fa-solid fa-check"></i>
<span data-i18n="Connect">
Connect
</span>
</div>
</div>
<label for="sd_vlad_auth" data-i18n="Authentication (optional)">Authentication (optional)</label>
<input id="sd_vlad_auth" type="text" class="text_pole" data-i18n="[placeholder]Example: username:password" placeholder="Example: username:password" value="" />
<i data-i18n="The server must be accessible from the SillyTavern host machine.">The server must be accessible from the SillyTavern host machine.</i>
</div>
<div data-sd-source="horde">
<i data-i18n="Hint: Save an API key in AI Horde API settings to use it here.">Hint: Save an API key in AI Horde API settings to use it here.</i>
<label for="sd_horde_nsfw" class="checkbox_label">
<input id="sd_horde_nsfw" type="checkbox" />
<span data-i18n="Allow NSFW images from Horde">
Allow NSFW images from Horde
</span>
</label>
<label for="sd_horde_sanitize" class="checkbox_label">
<input id="sd_horde_sanitize" type="checkbox" />
<span data-i18n="Sanitize prompts (recommended)">
Sanitize prompts (recommended)
</span>
</label>
</div>
<div data-sd-source="novel">
<div class="flex-container flexFlowColumn">
<label for="sd_novel_anlas_guard" class="checkbox_label flex1" data-i18n="[title]Automatically adjust generation parameters to ensure free image generations." title="Automatically adjust generation parameters to ensure free image generations.">
<input id="sd_novel_anlas_guard" type="checkbox" />
<span data-i18n="Avoid spending Anlas">
Avoid spending Anlas
</span>
<span data-i18n="Opus tier" class="toggle-description">(Opus tier)</span>
</label>
<div id="sd_novel_view_anlas" class="menu_button menu_button_icon" data-i18n="View my Anlas">
View my Anlas
</div>
</div>
<i data-i18n="Hint: Save an API key in the NovelAI API settings to use it here.">Hint: Save an API key in the NovelAI API settings to use it here.</i>
</div>
<div data-sd-source="aimlapi">
<div class="flex-container flexnowrap alignItemsBaseline marginBot5">
<strong class="flex1" data-i18n="API Key">API Key</strong>
<div id="sd_aimlapi_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_aimlapi">
<i class="fa-fw fa-solid fa-key"></i>
<span data-i18n="Click to set">Click to set</span>
</div>
</div>
</div>
<div data-sd-source="zai">
<b>Will use Common API. Coding API is not supported!</b>
</div>
<div data-sd-source="openai,aimlapi,zai">
<div class="flex-container">
<div data-sd-model="dall-e-3" class="flex1">
<label for="sd_openai_style" data-i18n="Image Style">Image Style</label>
<select id="sd_openai_style">
<option value="vivid">Vivid</option>
<option value="natural">Natural</option>
</select>
</div>
<div data-sd-model="dall-e-3,cogview-4-250304" class="flex1">
<label for="sd_openai_quality" data-i18n="Image Quality">Image Quality</label>
<select id="sd_openai_quality">
<option value="standard" data-i18n="Standard">Standard</option>
<option value="hd" data-i18n="HD">HD</option>
</select>
</div>
</div>
<div data-sd-model="sora-2,sora-2-pro" class="flex-container">
<div class="flex1">
<label for="sd_openai_duration" data-i18n="Duration">Duration</label>
<select id="sd_openai_duration">
<option value="4" data-i18n="Short (4 seconds)">Short (4 seconds)</option>
<option value="8" data-i18n="Medium (8 seconds)">Medium (8 seconds)</option>
<option value="12" data-i18n="Long (16 seconds)">Long (12 seconds)</option>
</select>
</div>
</div>
</div>
<div data-sd-source="comfy">
<label for="sd_comfy_type">Server Type</label>
<select id="sd_comfy_type">
<option value="standard">Standard Server</option>
<option value="runpod_serverless">RunPod Serverless Endpoint</option>
</select>
<div data-sd-comfy-type="standard">
<label for="sd_comfy_url">ComfyUI URL</label>
<div class="flex-container flexnowrap">
<input id="sd_comfy_url" type="text" class="text_pole" data-i18n="[placeholder]sd_comfy_url" 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>
</div>
<div data-sd-comfy-type="runpod_serverless">
<label for="sd_comfy_runpod_url">ComfyUI RunPod URL</label>
<div class="flex-container flexnowrap">
<input id="sd_comfy_runpod_url" type="text" class="text_pole" data-i18n="[placeholder]sd_comfy_runpod_url" placeholder="eg: https://api.runpod.ai/v2/<your endpoint id>" value="{{comfy_runpod_url}}" />
<div id="sd_comfy_runpod_validate" class="menu_button menu_button_icon">
<i class="fa-solid fa-check"></i>
<span data-i18n="Connect">
Connect
</span>
</div>
</div>
<div class="flex-container flexnowrap alignItemsBaseline marginBot5">
<a href="https://console.runpod.io/user/settings" target="_blank" rel="noopener noreferrer">
<strong data-i18n="API Key">API Key</strong>
<i class="fa-solid fa-share-from-square"></i>
</a>
<span class="expander"></span>
<div id="sd_runpod_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_comfy_runpod">
<i class="fa-fw fa-solid fa-key"></i>
<span data-i18n="Click to set">Click to set</span>
</div>
</div>
</div>
<p><i><b data-i18n="Important:">Important:</b></i><i data-i18n="The server must be accessible from the SillyTavern host machine."> 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" data-i18n="[title]Open workflow editor" 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" data-i18n="[title]Create new workflow" title="Create new workflow">
<i class="fa-solid fa-plus"></i>
</div>
<div id="sd_comfy_delete_workflow" class="menu_button menu_button_icon" data-i18n="[title]Delete workflow" title="Delete workflow">
<i class="fa-solid fa-trash-can"></i>
</div>
</div>
</div>
<div data-sd-source="pollinations">
<p>
<a href="https://pollinations.ai">Pollinations.ai</a>
</p>
<div class="flex-container">
<label class="flex1 checkbox_label" for="sd_pollinations_enhance" data-i18n="[title]Enables prompt enhancing (passes prompts through an LLM to add detail)." title="Enables prompt enhancing (passes prompts through an LLM to add detail).">
<input id="sd_pollinations_enhance" type="checkbox" />
<span data-i18n="Enhance">
Enhance
</span>
</label>
</div>
</div>
<div data-sd-source="stability">
<div class="flex-container flexnowrap alignItemsBaseline marginBot5">
<strong class="flex1" data-i18n="API Key">API Key</strong>
<div id="sd_stability_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_stability">
<i class="fa-fw fa-solid fa-key"></i>
<span data-i18n="Click to set">Click to set</span>
</div>
</div>
<div class="marginBot5">
<i data-i18n="You can find your API key in the Stability AI dashboard.">
You can find your API key in the Stability AI dashboard.
</i>
</div>
<div class="flex-container">
<div class="flex1">
<label for="sd_stability_style_preset" data-i18n="Style Preset">Style Preset</label>
<select id="sd_stability_style_preset">
<option value="anime">Anime</option>
<option value="3d-model">3D Model</option>
<option value="analog-film">Analog Film</option>
<option value="cinematic">Cinematic</option>
<option value="comic-book">Comic Book</option>
<option value="digital-art">Digital Art</option>
<option value="enhance">Enhance</option>
<option value="fantasy-art">Fantasy Art</option>
<option value="isometric">Isometric</option>
<option value="line-art">Line Art</option>
<option value="low-poly">Low Poly</option>
<option value="modeling-compound">Modeling Compound</option>
<option value="neon-punk">Neon Punk</option>
<option value="origami">Origami</option>
<option value="photographic">Photographic</option>
<option value="pixel-art">Pixel Art</option>
<option value="tile-texture">Tile Texture</option>
</select>
</div>
</div>
</div>
<div data-sd-source="bfl">
<div class="flex-container flexnowrap alignItemsBaseline marginBot5">
<a href="https://api.bfl.ml/" target="_blank" rel="noopener noreferrer">
<strong data-i18n="API Key">API Key</strong>
<i class="fa-solid fa-share-from-square"></i>
</a>
<span class="expander"></span>
<div id="sd_bfl_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_bfl">
<i class="fa-fw fa-solid fa-key"></i>
<span data-i18n="Click to set">Click to set</span>
</div>
</div>
<label class="checkbox_label marginBot5" for="sd_bfl_upsampling" data-i18n="[title]Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation." title="Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.">
<input id="sd_bfl_upsampling" type="checkbox" />
<span data-i18n="Prompt Upsampling">
Prompt Upsampling
</span>
</label>
</div>
<div data-sd-source="falai">
<div class="flex-container flexnowrap alignItemsBaseline marginBot5">
<a href="https://fal.ai/dashboard" target="_blank" rel="noopener noreferrer">
<strong data-i18n="API Key">API Key</strong>
<i class="fa-solid fa-share-from-square"></i>
</a>
<span class="expander"></span>
<div id="sd_falai_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_falai">
<i class="fa-fw fa-solid fa-key"></i>
<span data-i18n="Click to set">Click to set</span>
</div>
</div>
</div>
<div data-sd-source="google">
<div class="flex-container">
<div class="flex1">
<label for="sd_google_api" data-i18n="API Type">API Type</label>
<select id="sd_google_api" class="text_pole">
<option value="makersuite">Google AI Studio</option>
<option value="vertexai">Google Vertex AI</option>
</select>
</div>
</div>
<div class="flex-container alignItemsCenter">
<label class="flex1 checkbox_label" for="sd_google_enhance" data-i18n="[title]Enables prompt enhancing (passes prompts through an LLM to add detail)." title="Enables prompt enhancing (passes prompts through an LLM to add detail).">
<input id="sd_google_enhance" type="checkbox" />
<span data-i18n="Enhance">
Enhance
</span>
</label>
<div class="flex1">
<label for="sd_google_duration" data-i18n="Duration (Veo)">Duration (Veo)</label>
<select id="sd_google_duration">
<option value="4">Short (4 seconds)</option>
<option value="6">Medium (6 seconds)</option>
<option value="8">Long (8 seconds)</option>
</select>
</div>
</div>
</div>
<div class="flex-container">
<div class="flex1">
<label for="sd_model" data-i18n="Model">Model</label>
<select id="sd_model"></select>
</div>
<div class="flex1" data-sd-source="comfy,auto">
<label for="sd_vae">VAE</label>
<select id="sd_vae"></select>
</div>
</div>
<div class="flex-container">
<div class="flex1" data-sd-source="extras,horde,auto,drawthings,novel,vlad,comfy">
<label for="sd_sampler" data-i18n="Sampling method">Sampling method</label>
<select id="sd_sampler"></select>
</div>
<div class="flex1" data-sd-source="comfy,auto,novel">
<label for="sd_scheduler" data-i18n="Scheduler">Scheduler</label>
<select id="sd_scheduler"></select>
</div>
</div>
<div class="flex-container">
<div class="flex1">
<label for="sd_resolution" data-i18n="Resolution">Resolution</label>
<select id="sd_resolution"><!-- Populated in JS --></select>
</div>
<div class="flex1" data-sd-source="auto,vlad,drawthings">
<label for="sd_hr_upscaler" data-i18n="Upscaler">Upscaler</label>
<select id="sd_hr_upscaler"></select>
</div>
</div>
<div class="flex-container">
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p">
<small>
<span data-i18n="Sampling steps">Sampling steps</span>
</small>
<input class="neo-range-slider" type="range" id="sd_steps" name="sd_steps" min="{{steps_min}}" max="{{steps_max}}" step="{{steps_step}}" value="{{steps}}" >
<input class="neo-range-input" type="number" id="sd_steps_value" data-for="sd_steps" min="{{steps_min}}" max="{{steps_max}}" step="{{steps_step}}" value="{{steps}}" >
</div>
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p">
<small>
<span data-i18n="CFG Scale">CFG Scale</span>
</small>
<input class="neo-range-slider" type="range" id="sd_scale" name="sd_scale" min="{{scale_min}}" max="{{scale_max}}" step="{{scale_step}}" value="{{scale}}" >
<input class="neo-range-input" type="number" id="sd_scale_value" data-for="sd_scale" min="{{scale_min}}" max="{{scale_max}}" step="{{scale_step}}" value="{{scale}}" >
</div>
</div>
<div id="sd_dimensions_block" class="flex-container">
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p">
<small>
<span data-i18n="Width">Width</span>
</small>
<input class="neo-range-slider" type="range" id="sd_width" name="sd_width" max="{{dimension_max}}" min="{{dimension_min}}" step="{{dimension_step}}" value="{{width}}" >
<input class="neo-range-input" type="number" id="sd_width_value" data-for="sd_width" max="{{dimension_max}}" min="{{dimension_min}}" step="{{dimension_step}}" value="{{width}}" >
</div>
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p">
<small>
<span data-i18n="Height">Height</span>
</small>
<input class="neo-range-slider" type="range" id="sd_height" name="sd_height" max="{{dimension_max}}" min="{{dimension_min}}" step="{{dimension_step}}" value="{{height}}" >
<input class="neo-range-input" type="number" id="sd_height_value" data-for="sd_height" max="{{dimension_max}}" min="{{dimension_min}}" step="{{dimension_step}}" value="{{height}}" >
</div>
<div id="sd_swap_dimensions" class="right_menu_button" title="Swap width and height" data-i18n="[title]Swap width and height">
<i class="fa-solid fa-arrow-right-arrow-left"></i>
</div>
</div>
<div class="flex-container">
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad,drawthings,novel">
<small>
<span data-i18n="Upscale by">Upscale by</span>
</small>
<input class="neo-range-slider" type="range" id="sd_hr_scale" name="sd_hr_scale" min="{{hr_scale_min}}" max="{{hr_scale_max}}" step="{{hr_scale_step}}" value="{{hr_scale}}" >
<input class="neo-range-input" type="number" id="sd_hr_scale_value" data-for="sd_hr_scale" min="{{hr_scale_min}}" max="{{hr_scale_max}}" step="{{hr_scale_step}}" value="{{hr_scale}}" >
</div>
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad,comfy">
<small>
<span data-i18n="Denoising strength">Denoising strength</span>
</small>
<input class="neo-range-slider" type="range" id="sd_denoising_strength" name="sd_denoising_strength" min="{{denoising_strength_min}}" max="{{denoising_strength_max}}" step="{{denoising_strength_step}}" value="{{denoising_strength}}" >
<input class="neo-range-input" type="number" id="sd_denoising_strength_value" data-for="sd_denoising_strength" min="{{denoising_strength_min}}" max="{{denoising_strength_max}}" step="{{denoising_strength_step}}" value="{{denoising_strength}}" >
</div>
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad">
<small>
<span data-i18n="Hires steps (2nd pass)">Hires steps (2nd pass)</span>
</small>
<input class="neo-range-slider" type="range" id="sd_hr_second_pass_steps" name="sd_hr_second_pass_steps" max="{{hr_second_pass_steps_max}}" step="{{hr_second_pass_steps_step}}" value="{{hr_second_pass_steps}}" >
<input class="neo-range-input" type="number" id="sd_hr_second_pass_steps_value" data-for="sd_hr_second_pass_steps" max="{{hr_second_pass_steps_max}}" step="{{hr_second_pass_steps_step}}" value="{{hr_second_pass_steps}}" >
</div>
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad,comfy,horde,drawthings,extras">
<small>
<span data-i18n="CLIP Skip">CLIP Skip</span>
</small>
<input class="neo-range-slider" type="range" id="sd_clip_skip" name="sd_clip_skip" min="{{clip_skip_min}}" max="{{clip_skip_max}}" step="{{clip_skip_step}}" value="{{clip_skip}}" >
<input class="neo-range-input" type="number" id="sd_clip_skip_value" data-for="sd_clip_skip" min="{{clip_skip_min}}" max="{{clip_skip_max}}" step="{{clip_skip_step}}" value="{{clip_skip}}" >
</div>
</div>
<div class="flex-container marginTopBot5" data-sd-source="auto,vlad,extras,horde,drawthings">
<label class="flex1 checkbox_label">
<input id="sd_restore_faces" type="checkbox" />
<small data-i18n="Restore Faces">Restore Faces</small>
</label>
<label class="flex1 checkbox_label">
<input id="sd_enable_hr" type="checkbox" />
<small data-i18n="Hires. Fix">Hires. Fix</small>
</label>
<label data-sd-source="horde" for="sd_horde_karras" class="flex1 checkbox_label">
<input id="sd_horde_karras" type="checkbox" />
<small data-i18n="Karras">Karras</small>
<i class="fa-solid fa-info-circle fa-sm opacity50p" data-i18n="[title]Not all samplers supported." title="Not all samplers supported."></i>
</label>
</div>
<div class="flex-container marginTopBot5" data-sd-source="auto,vlad">
<label for="sd_adetailer_face" class="flex1 checkbox_label" data-i18n="[title]sd_adetailer_face" title="Use ADetailer with face model during the generation. The ADetailer extension must be installed on the backend.">
<input id="sd_adetailer_face" type="checkbox" />
<small data-i18n="Use ADetailer (Face)">Use ADetailer (Face)</small>
</label>
<div class="flex1">
<!-- I will be useful later! -->
</div>
</div>
<div class="flex-container marginTopBot5" data-sd-source="novel">
<label class="flex1 checkbox_label" data-i18n="[title]SMEA versions of samplers are modified to perform better at high resolution." title="SMEA versions of samplers are modified to perform better at high resolution.">
<input id="sd_novel_sm" type="checkbox" />
<small data-i18n="SMEA">SMEA</small>
</label>
<label class="flex1 checkbox_label" data-i18n="[title]DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions." title="DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions.">
<input id="sd_novel_sm_dyn" type="checkbox" />
<small data-i18n="DYN">DYN</small>
</label>
<label class="flex1 checkbox_label" for="sd_novel_decrisper" title="Reduce artifacts caused by high guidance values.">
<input id="sd_novel_decrisper" type="checkbox" />
<small data-i18n="Decrisper">Decrisper</small>
</label>
<label class="flex1 checkbox_label" data-i18n="[title]Enable guidance only after body has been formed, to improve diversity and saturation of samples. May reduce relevance" title="Enable guidance only after body has been formed, to improve diversity and saturation of samples. May reduce relevance">
<input id="sd_novel_variety_boost" type="checkbox" />
<small data-i18n="Variety+">Variety+</small>
</label>
</div>
<div data-sd-source="novel,togetherai,pollinations,comfy,drawthings,vlad,auto,horde,extras,stability,bfl" class="marginTop5">
<label for="sd_seed">
<span data-i18n="Seed">Seed</span>
<small data-i18n="(-1 for random)">(-1 for random)</small>
</label>
<input id="sd_seed" type="number" class="text_pole" min="-1" max="9999999999" step="1" />
</div>
<hr>
<h4 data-i18n="[title]Preset for prompt prefix and negative prompt" title="Preset for prompt prefix and negative prompt">
<span data-i18n="Style">Style</span>
</h4>
<div class="flex-container">
<select id="sd_style" class="flex1 text_pole"></select>
<div id="sd_save_style" data-i18n="[title]Save style" title="Save style" class="menu_button">
<i class="fa-solid fa-save"></i>
</div>
<div id="sd_delete_style" data-i18n="[title]Delete style" title="Delete style" class="menu_button">
<i class="fa-solid fa-trash-can"></i>
</div>
</div>
<label for="sd_prompt_prefix" data-i18n="Common prompt prefix">Common prompt prefix</label>
<textarea id="sd_prompt_prefix" class="text_pole textarea_compact autoSetHeight" data-i18n="[placeholder]sd_prompt_prefix_placeholder" placeholder="Use {prompt} to specify where the generated prompt will be inserted"></textarea>
<label for="sd_negative_prompt" data-i18n="Negative common prompt prefix">Negative common prompt prefix</label>
<textarea id="sd_negative_prompt" class="text_pole textarea_compact autoSetHeight"></textarea>
<div id="sd_character_prompt_block">
<label for="sd_character_prompt" data-i18n="Character-specific prompt prefix">Character-specific prompt prefix</label>
<small data-i18n="Won't be used in groups.">Won't be used in groups.</small>
<textarea id="sd_character_prompt" class="text_pole textarea_compact autoSetHeight" data-i18n="[placeholder]sd_character_prompt_placeholder" placeholder="Any characteristics that describe the currently selected character. Will be added after a common prompt prefix.&#10;Example: female, green eyes, brown hair, pink shirt"></textarea>
<label for="sd_character_negative_prompt" data-i18n="Character-specific negative prompt prefix">Character-specific negative prompt prefix</label>
<small data-i18n="Won't be used in groups.">Won't be used in groups.</small>
<textarea id="sd_character_negative_prompt" class="text_pole textarea_compact autoSetHeight" data-i18n="[placeholder]sd_character_negative_prompt_placeholder" placeholder="Any characteristics that should not appear for the selected character. Will be added after a negative common prompt prefix.&#10;Example: jewellery, shoes, glasses"></textarea>
<label for="sd_character_prompt_share" class="checkbox_label flexWrap marginTop5">
<input id="sd_character_prompt_share" type="checkbox" />
<span data-i18n="Shareable">
Shareable
</span>
<small class="flexBasis100p">
When checked, character-specific prompts will be saved with the character card data.
</small>
</label>
</div>
<hr>
<h4 data-i18n="Chat Message Visibility (by source)">
Chat Message Visibility (by source)
</h4>
<small data-i18n="Uncheck to hide the extension's messages in chat prompts.">
Uncheck to hide the extension's messages in chat prompts.
</small>
<div class="flex-container flexFlowColumn marginTopBot5 flexGap10">
<label for="sd_wand_visible" class="checkbox_label">
<span class="flex1 flex-container alignItemsCenter">
<i class="fa-solid fa-wand-magic-sparkles fa-fw"></i>
<span data-i18n="Extensions Menu">Extensions Menu</span>
</span>
<input id="sd_wand_visible" type="checkbox" />
</label>
<label for="sd_command_visible" class="checkbox_label">
<span class="flex1 flex-container alignItemsCenter">
<i class="fa-solid fa-terminal fa-fw"></i>
<span data-i18n="Slash Command">Slash Command</span>
</span>
<input id="sd_command_visible" type="checkbox" />
</label>
<label for="sd_interactive_visible" class="checkbox_label">
<span class="flex1 flex-container alignItemsCenter">
<i class="fa-solid fa-message fa-fw"></i>
<span data-i18n="Interactive Mode">Interactive Mode</span>
</span>
<input id="sd_interactive_visible" type="checkbox" />
</label>
<label for="sd_tool_visible" class="checkbox_label">
<span class="flex1 flex-container alignItemsCenter">
<i class="fa-solid fa-wrench fa-fw"></i>
<span data-i18n="Function Tool">Function Tool</span>
</span>
<input id="sd_tool_visible" type="checkbox" />
</label>
</div>
</div>
</div>
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b data-i18n="Image Prompt Templates">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">
</div>
</div>
</div>

View File

@@ -0,0 +1,93 @@
.sd_settings label:not(.checkbox_label) {
display: block;
}
#sd_dropdown {
z-index: 30000;
backdrop-filter: blur(var(--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;
}
.sd_comfy_workflow_editor_placeholder_list input {
font-size: inherit;
margin: 0;
}
.sd_comfy_workflow_editor_custom_remove, #sd_comfy_workflow_editor_placeholder_add {
cursor: pointer;
font-weight: bold;
width: 1em;
opacity: 0.5;
&:hover {
opacity: 1;
}
}
.sd_settings .flex1.checkbox_label input[type="checkbox"] {
margin-right: 5px;
margin-left: 5px;
}
#sd_dimensions_block {
position: relative;
}
#sd_swap_dimensions {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
}

View File

@@ -0,0 +1,58 @@
# EditorConfig 配置文件
# https://editorconfig.org
root = true
# 通用设置,针对所有文件
[*]
# 使用空格缩进(对应 prettier.useTabs = false
indent_style = space
# 缩进为 2 个空格(对应 prettier.tabWidth = 2
indent_size = 2
# 若编辑器支持,统一制表符宽度也设为 2
tab_width = 2
# 文件编码(推荐统一为 UTF-8无 BOM
charset = utf-8
# 行尾符统一使用 LFLinux / macOS 风格)
end_of_line = lf
# 删除行尾多余空格
trim_trailing_whitespace = true
# 文件末尾一定要空一行
insert_final_newline = true
# 控制最大行长度,建议配合编辑器可视化标尺(对应 prettier.printWidth = 120
# 该字段并非官方标准,但常见于 EditorConfig 扩展
max_line_length = 120
# 注意:以下 prettier 专有配置项需保留在 .prettierrc 或相关配置文件内
# arrowParens = avoid
# bracketSpacing = true
# jsxBracketSameLine = false
# proseWrap = always
# quoteProps = as-needed
# semi = true
# singleQuote = true
# trailingComma = all
###############################
# 针对特定文件类型的覆盖
###############################
# Markdown 文件通常不删除行尾空白,以保留软换行格式
[*.md]
trim_trailing_whitespace = false
# JSON 文件统一双引号,不按 prettier.singleQuote
[*.json]
indent_style = space
indent_size = 2
# YAML 文件也保留双引号,且通常可以不删除行尾空白(可视项目需求)
[*.yml]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false

View File

@@ -0,0 +1,5 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
dist/** merge=ours

View File

@@ -0,0 +1,42 @@
name: bump_deps
on:
schedule:
- cron: '0 0 */3 * *'
workflow_dispatch:
jobs:
bump_deps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Use Node.js
uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Setup pnpm
uses: pnpm/action-setup@v4.1.0
with: { version: 10 }
- run: |
pnpm update
pnpm install --lockfile-only
- name: Check if only pnpm-lock.yaml is changed
id: should_commit
run: |-
export changed_files=$(git diff --name-only HEAD | grep -v '^$')
export num_changed_files=$(echo "$changed_files" | wc -l)
if [ "$num_changed_files" -eq 1 ] && [ "$changed_files" = "pnpm-lock.yaml" ]; then
echo "result=false" >> "$GITHUB_OUTPUT"
else
echo "result=true" >> "$GITHUB_OUTPUT"
fi
shell: bash
- name: Commit if not only pnpm-lock.yaml is changed
if: steps.should_commit.outputs.result == 'true'
uses: EndBug/add-and-commit@v9.1.3
with:
default_author: github_actions
message: '[bot] Bump deps'

View File

@@ -0,0 +1,120 @@
name: bundle
on:
push:
branches:
- main
- dev
paths-ignore:
- dist/**
workflow_dispatch:
permissions:
actions: read
contents: write
concurrency:
group: ${{ github.workflow }}
jobs:
bundle:
runs-on: ubuntu-latest
steps:
# checkout
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: JesseTG/rm@v1.0.3
with:
path: dist
# tag if [release...] is in the commit message subject
- id: autotag_check
shell: bash
run: |-
message=$(git log -n 1 --pretty=format:"%s")
if [[ $message == *"[release]"* || $message == *"[release patch]"* ]]; then
echo "should_tag=true" >> "$GITHUB_OUTPUT"
echo "bump=patch" >> "$GITHUB_OUTPUT"
elif [[ $message == *"[release minor]"* ]]; then
echo "should_tag=true" >> "$GITHUB_OUTPUT"
echo "bump=minor" >> "$GITHUB_OUTPUT"
elif [[ $message == *"[release major]"* ]]; then
echo "should_tag=true" >> "$GITHUB_OUTPUT"
echo "bump=major" >> "$GITHUB_OUTPUT"
else
echo "should_tag=false" >> "$GITHUB_OUTPUT"
echo "bump=" >> "$GITHUB_OUTPUT"
fi
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type != 'tag' && steps.autotag_check.outputs.should_tag == 'true' }}
id: autotag
uses: phish108/autotag-action@v1.1.64
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
bump: ${{ steps.autotag_check.outputs.bump }}
release-branch: ''
dry-run: true
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type == 'tag' }}
id: manual_tag
env:
VALUE: ${{ github.ref }}
run: |-
export TAG=${VALUE##refs/tags/}
echo result=$TAG >> "$GITHUB_OUTPUT"
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type == 'tag' || steps.autotag_check.outputs.should_tag == 'true' }}
id: package_version
run: |-
echo 'result="version": "${{ github.ref_type == 'tag' && steps.manual_tag.outputs.result || steps.autotag.outputs.new-tag }}"' >> "$GITHUB_OUTPUT"
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type == 'tag' || steps.autotag_check.outputs.should_tag == 'true' }}
uses: jacobtomlinson/gha-find-replace@v3
with:
include: manifest.json
find: '"version": "\d+\.\d+\.\d+"'
replace: ${{ steps.package_version.outputs.result }}
regex: true
- if: ${{ github.ref != 'refs/heads/dev' && github.ref_type == 'tag' || steps.autotag_check.outputs.should_tag == 'true' }}
uses: jacobtomlinson/gha-find-replace@v3
with:
include: package.json
find: '"version": "\d+\.\d+\.\d+"'
replace: ${{ steps.package_version.outputs.result }}
regex: true
# build after tag change
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4.1.0
with:
version: 10
- run: pnpm i
- run: pnpm build
- name: Merge @types files to a single file
run: |-
mkdir -p dist
cat @types/**/*.d.ts > dist/@types.txt
- name: Compress @types files to a zip
run: |-
tar \
--sort=name \
--mtime='UTC 1980-02-01' \
--owner=0 --group=0 --numeric-owner \
--pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime \
-caf ../dist/@types.zip .
working-directory: '@types'
# commit
- uses: EndBug/add-and-commit@v9.1.3
with:
default_author: github_actions
message: '[bot] Bundle'
- if: ${{ github.ref_type == 'tag' }}
shell: bash
run: |-
git tag -d ${{ steps.manual_tag.outputs.result }}
git push --delete origin ${{ steps.manual_tag.outputs.result }}
- if: ${{ github.ref_type == 'tag' || steps.autotag_check.outputs.should_tag == 'true' }}
shell: bash
run: |-
git tag ${{ github.ref_type == 'tag' && steps.manual_tag.outputs.result || steps.autotag.outputs.new-tag }}
git push --tags

View File

@@ -0,0 +1,22 @@
name: sync
on:
workflow_run:
workflows:
- bundle
types:
- completed
workflow_dispatch:
concurrency:
group: ${{github.workflow}}
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: keninkujovic/gitlab-sync@2.0.0
with:
gitlab_url: https://gitlab.com/novi028/JS-Slash-Runner.git
username: novi028
gitlab_pat: ${{ secrets.GITLAB_PAT }}

View File

@@ -0,0 +1,14 @@
/node_modules
.DS_Store
Thumbs.db
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
auto-imports.d.ts
components.d.ts

View File

@@ -0,0 +1 @@
dist

View File

@@ -0,0 +1,12 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"printWidth": 120,
"proseWrap": "always",
"quoteProps": "as-needed",
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false
}

View File

@@ -0,0 +1,98 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "编译代码并调试酒馆网页 (Chrome)",
"type": "chrome",
"request": "launch",
"preLaunchTask": "开始监听源代码并编译",
"postDebugTask": "结束监听源代码并编译",
"url": "http://localhost:8000",
"disableNetworkCache": true,
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"cwd": "${workspaceFolder}",
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "编译代码并调试酒馆网页 (Edge)",
"type": "msedge",
"request": "launch",
"preLaunchTask": "开始监听源代码并编译",
"postDebugTask": "结束监听源代码并编译",
"url": "http://localhost:8000",
"disableNetworkCache": true,
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"cwd": "${workspaceFolder}",
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "编译代码并调试酒馆网页 (Firefox)",
"type": "firefox",
"request": "launch",
"preLaunchTask": "开始监听源代码并编译",
"postDebugTask": "结束监听源代码并编译",
"url": "http://localhost:8000",
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"suggestPathMappingWizard": true,
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "仅调试酒馆网页 (Chrome)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:8000",
"disableNetworkCache": true,
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"cwd": "${workspaceFolder}",
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "仅调试酒馆网页 (Edge)",
"type": "msedge",
"request": "launch",
"url": "http://localhost:8000",
"disableNetworkCache": true,
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"cwd": "${workspaceFolder}",
"timeout": 1000000,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
},
{
"name": "仅调试酒馆网页 (Firefox)",
"type": "firefox",
"request": "launch",
"url": "http://localhost:8000",
"internalConsoleOptions": "neverOpen",
"webRoot": "${workspaceFolder}/../../../../",
"suggestPathMappingWizard": true,
"skipFiles": [
"**/jquery*.min.js",
"**/node_modules/**"
],
}
]
}

Some files were not shown because too many files have changed in this diff Show More