mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Merge branch 'SillyTavern:release' into release
This commit is contained in:
@@ -286,6 +286,7 @@ jQuery(function () {
|
||||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'google' && secret_state[SECRET_KEYS.MAKERSUITE]) ||
|
||||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'ollama' && textgenerationwebui_settings.server_urls[textgen_types.OLLAMA]) ||
|
||||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'llamacpp' && textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP]) ||
|
||||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'ooba' && textgenerationwebui_settings.server_urls[textgen_types.OOBA]) ||
|
||||
(extension_settings.caption.source === 'multimodal' && extension_settings.caption.multimodal_api === 'custom') ||
|
||||
extension_settings.caption.source === 'local' ||
|
||||
extension_settings.caption.source === 'horde';
|
||||
@@ -351,6 +352,7 @@ jQuery(function () {
|
||||
<label for="caption_multimodal_api">API</label>
|
||||
<select id="caption_multimodal_api" class="flex1 text_pole">
|
||||
<option value="llamacpp">llama.cpp</option>
|
||||
<option value="ooba">Text Generation WebUI (oobabooga)</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
@@ -369,6 +371,7 @@ jQuery(function () {
|
||||
<option data-type="ollama" value="bakllava:latest">bakllava:latest</option>
|
||||
<option data-type="ollama" value="llava:latest">llava:latest</option>
|
||||
<option data-type="llamacpp" value="llamacpp_current">[Currently loaded]</option>
|
||||
<option data-type="ooba" value="ooba_current">[Currently loaded]</option>
|
||||
<option data-type="custom" value="custom_current">[Currently selected]</option>
|
||||
</select>
|
||||
</div>
|
||||
|
@@ -10,6 +10,7 @@ export { MODULE_NAME };
|
||||
const MODULE_NAME = 'expressions';
|
||||
const UPDATE_INTERVAL = 2000;
|
||||
const STREAMING_UPDATE_INTERVAL = 6000;
|
||||
const TALKINGCHECK_UPDATE_INTERVAL = 500;
|
||||
const FALLBACK_EXPRESSION = 'joy';
|
||||
const DEFAULT_EXPRESSIONS = [
|
||||
'talkinghead',
|
||||
@@ -46,9 +47,16 @@ const DEFAULT_EXPRESSIONS = [
|
||||
let expressionsList = null;
|
||||
let lastCharacter = undefined;
|
||||
let lastMessage = null;
|
||||
let lastTalkingState = false;
|
||||
let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer)
|
||||
let spriteCache = {};
|
||||
let inApiCall = false;
|
||||
let lastServerResponseTime = 0;
|
||||
export let lastExpression = {};
|
||||
|
||||
function isTalkingHeadEnabled() {
|
||||
return extension_settings.expressions.talkinghead && !extension_settings.expressions.local;
|
||||
}
|
||||
|
||||
function isVisualNovelMode() {
|
||||
return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);
|
||||
@@ -380,7 +388,10 @@ function onExpressionsShowDefaultInput() {
|
||||
}
|
||||
}
|
||||
|
||||
async function unloadLiveChar() {
|
||||
/**
|
||||
* Stops animating a talkinghead.
|
||||
*/
|
||||
async function unloadTalkingHead() {
|
||||
if (!modules.includes('talkinghead')) {
|
||||
console.debug('talkinghead module is disabled');
|
||||
return;
|
||||
@@ -399,7 +410,10 @@ async function unloadLiveChar() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLiveChar() {
|
||||
/**
|
||||
* Posts `talkinghead.png` of the current character to the talkinghead module in SillyTavern-extras, to start animating it.
|
||||
*/
|
||||
async function loadTalkingHead() {
|
||||
if (!modules.includes('talkinghead')) {
|
||||
console.debug('talkinghead module is disabled');
|
||||
return;
|
||||
@@ -408,6 +422,8 @@ async function loadLiveChar() {
|
||||
const spriteFolderName = getSpriteFolderName();
|
||||
|
||||
const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`;
|
||||
const emotionsSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_emotions.json`;
|
||||
const animatorSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_animator.json`;
|
||||
|
||||
try {
|
||||
const spriteResponse = await fetch(talkingheadPath);
|
||||
@@ -436,6 +452,69 @@ async function loadLiveChar() {
|
||||
const loadResponseText = await loadResponse.text();
|
||||
console.log(`Load talkinghead response: ${loadResponseText}`);
|
||||
|
||||
// Optional: per-character emotion templates
|
||||
let emotionsSettings;
|
||||
try {
|
||||
const emotionsResponse = await fetch(emotionsSettingsPath);
|
||||
if (emotionsResponse.ok) {
|
||||
emotionsSettings = await emotionsResponse.json();
|
||||
console.log(`Loaded ${emotionsSettingsPath}`);
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
emotionsSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
|
||||
console.log(`No valid config at ${emotionsSettingsPath}, using server defaults`);
|
||||
}
|
||||
try {
|
||||
const url = new URL(getApiUrl());
|
||||
url.pathname = '/api/talkinghead/load_emotion_templates';
|
||||
const apiResult = await doExtrasFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Bypass-Tunnel-Reminder': 'bypass',
|
||||
},
|
||||
body: JSON.stringify(emotionsSettings),
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
// it's ok if not supported
|
||||
console.log('Failed to send _emotions.json (backend too old?), ignoring');
|
||||
}
|
||||
|
||||
// Optional: per-character animator and postprocessor config
|
||||
let animatorSettings;
|
||||
try {
|
||||
const animatorResponse = await fetch(animatorSettingsPath);
|
||||
if (animatorResponse.ok) {
|
||||
animatorSettings = await animatorResponse.json();
|
||||
console.log(`Loaded ${animatorSettingsPath}`);
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
animatorSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
|
||||
console.log(`No valid config at ${animatorSettingsPath}, using server defaults`);
|
||||
}
|
||||
try {
|
||||
const url = new URL(getApiUrl());
|
||||
url.pathname = '/api/talkinghead/load_animator_settings';
|
||||
const apiResult = await doExtrasFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Bypass-Tunnel-Reminder': 'bypass',
|
||||
},
|
||||
body: JSON.stringify(animatorSettings),
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
// it's ok if not supported
|
||||
console.log('Failed to send _animator.json (backend too old?), ignoring');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`);
|
||||
}
|
||||
@@ -449,7 +528,7 @@ function handleImageChange() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (extension_settings.expressions.talkinghead && !extension_settings.expressions.local) {
|
||||
if (isTalkingHeadEnabled()) {
|
||||
// Method get IP of endpoint
|
||||
const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`;
|
||||
$('#expression-holder').css({ display: '' });
|
||||
@@ -558,9 +637,10 @@ async function moduleWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastMessage === currentLastMessage.mes);
|
||||
|
||||
// check if last message changed
|
||||
if ((lastCharacter === context.characterId || lastCharacter === context.groupId)
|
||||
&& lastMessage === currentLastMessage.mes) {
|
||||
if (!lastMessageChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -610,21 +690,81 @@ async function moduleWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
async function talkingHeadCheck() {
|
||||
/**
|
||||
* Starts/stops talkinghead talking animation.
|
||||
*
|
||||
* Talking starts only when all the following conditions are met:
|
||||
* - The LLM is currently streaming its output.
|
||||
* - The AI's current last message is non-empty, and also not just '...' (as produced by a swipe).
|
||||
* - The AI's current last message has changed from what we saw during the previous call.
|
||||
*
|
||||
* In all other cases, talking stops.
|
||||
*
|
||||
* A talkinghead API call is made only when the talking state changes.
|
||||
*/
|
||||
async function updateTalkingState() {
|
||||
// Don't bother if talkinghead is disabled or not loaded.
|
||||
if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = getContext();
|
||||
const currentLastMessage = getLastCharacterMessage();
|
||||
|
||||
try {
|
||||
// TODO: Not sure if we need also "&& !context.groupId" here - the classify check in `moduleWorker`
|
||||
// (that similarly checks the streaming processor state) does that for some reason.
|
||||
// Talkinghead isn't currently designed to work with groups.
|
||||
const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastTalkingStateMessage === currentLastMessage.mes);
|
||||
const url = new URL(getApiUrl());
|
||||
let newTalkingState;
|
||||
if (context.streamingProcessor && !context.streamingProcessor.isFinished &&
|
||||
currentLastMessage.mes.length !== 0 && currentLastMessage.mes !== '...' && lastMessageChanged) {
|
||||
url.pathname = '/api/talkinghead/start_talking';
|
||||
newTalkingState = true;
|
||||
} else {
|
||||
url.pathname = '/api/talkinghead/stop_talking';
|
||||
newTalkingState = false;
|
||||
}
|
||||
try {
|
||||
// Call the talkinghead API only if the talking state changed.
|
||||
if (newTalkingState !== lastTalkingState) {
|
||||
console.debug(`updateTalkingState: calling ${url.pathname}`);
|
||||
await doExtrasFetch(url);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// it's ok if not supported
|
||||
}
|
||||
finally {
|
||||
lastTalkingState = newTalkingState;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// console.log(error);
|
||||
}
|
||||
finally {
|
||||
lastTalkingStateMessage = currentLastMessage.mes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current character has a talkinghead image available.
|
||||
* @returns {Promise<boolean>} True if the character has a talkinghead image available, false otherwise.
|
||||
*/
|
||||
async function isTalkingHeadAvailable() {
|
||||
let spriteFolderName = getSpriteFolderName();
|
||||
|
||||
try {
|
||||
await validateImages(spriteFolderName);
|
||||
|
||||
let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead');
|
||||
let talkingheadPath_f = talkingheadObj ? talkingheadObj.path : null;
|
||||
let talkingheadPath = talkingheadObj ? talkingheadObj.path : null;
|
||||
|
||||
if (talkingheadPath_f != null) {
|
||||
//console.log("talkingheadPath_f " + talkingheadPath_f);
|
||||
if (talkingheadPath != null) {
|
||||
return true;
|
||||
} else {
|
||||
//console.log("talkingheadPath_f is null");
|
||||
unloadLiveChar();
|
||||
await unloadTalkingHead();
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -646,22 +786,22 @@ function getSpriteFolderName(characterMessage = null, characterName = null) {
|
||||
return spriteFolderName;
|
||||
}
|
||||
|
||||
function setTalkingHeadState(switch_var) {
|
||||
extension_settings.expressions.talkinghead = switch_var; // Store setting
|
||||
function setTalkingHeadState(newState) {
|
||||
extension_settings.expressions.talkinghead = newState; // Store setting
|
||||
saveSettingsDebounced();
|
||||
|
||||
if (extension_settings.expressions.local) {
|
||||
return;
|
||||
}
|
||||
|
||||
talkingHeadCheck().then(result => {
|
||||
isTalkingHeadAvailable().then(result => {
|
||||
if (result) {
|
||||
//console.log("talkinghead exists!");
|
||||
|
||||
if (extension_settings.expressions.talkinghead) {
|
||||
loadLiveChar();
|
||||
loadTalkingHead();
|
||||
} else {
|
||||
unloadLiveChar();
|
||||
unloadTalkingHead();
|
||||
}
|
||||
handleImageChange(); // Change image as needed
|
||||
|
||||
@@ -692,6 +832,7 @@ function getFolderNameByMessage(message) {
|
||||
}
|
||||
|
||||
async function sendExpressionCall(name, expression, force, vnMode) {
|
||||
lastExpression[name.split('/')[0]] = expression;
|
||||
if (!vnMode) {
|
||||
vnMode = isVisualNovelMode();
|
||||
}
|
||||
@@ -730,22 +871,29 @@ async function setSpriteSlashCommand(_, spriteId) {
|
||||
|
||||
spriteId = spriteId.trim().toLowerCase();
|
||||
|
||||
// In talkinghead mode, don't check for the existence of the sprite
|
||||
// (emotion names are the same as for sprites, but it only needs "talkinghead.png").
|
||||
const currentLastMessage = getLastCharacterMessage();
|
||||
const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
|
||||
await validateImages(spriteFolderName);
|
||||
let label = spriteId;
|
||||
if (!isTalkingHeadEnabled()) {
|
||||
await validateImages(spriteFolderName);
|
||||
|
||||
// Fuzzy search for sprite
|
||||
const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
|
||||
const results = fuse.search(spriteId);
|
||||
const spriteItem = results[0]?.item;
|
||||
// Fuzzy search for sprite
|
||||
const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
|
||||
const results = fuse.search(spriteId);
|
||||
const spriteItem = results[0]?.item;
|
||||
|
||||
if (!spriteItem) {
|
||||
console.log('No sprite found for search term ' + spriteId);
|
||||
return;
|
||||
if (!spriteItem) {
|
||||
console.log('No sprite found for search term ' + spriteId);
|
||||
return;
|
||||
}
|
||||
|
||||
label = spriteItem.label;
|
||||
}
|
||||
|
||||
const vnMode = isVisualNovelMode();
|
||||
await sendExpressionCall(spriteFolderName, spriteItem.label, true, vnMode);
|
||||
await sendExpressionCall(spriteFolderName, label, true, vnMode);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -996,7 +1144,7 @@ async function getExpressionsList() {
|
||||
}
|
||||
|
||||
async function setExpression(character, expression, force) {
|
||||
if (extension_settings.expressions.local || !extension_settings.expressions.talkinghead) {
|
||||
if (!isTalkingHeadEnabled()) {
|
||||
console.debug('entered setExpressions');
|
||||
await validateImages(character);
|
||||
const img = $('img.expression');
|
||||
@@ -1107,24 +1255,38 @@ async function setExpression(character, expression, force) {
|
||||
document.getElementById('expression-holder').style.display = '';
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
talkingHeadCheck().then(result => {
|
||||
// Set the talkinghead emotion to the specified expression
|
||||
// TODO: For now, talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode.
|
||||
try {
|
||||
let result = await isTalkingHeadAvailable();
|
||||
if (result) {
|
||||
// Find the <img> element with id="expression-image" and class="expression"
|
||||
const imgElement = document.querySelector('img#expression-image.expression');
|
||||
//console.log("searching");
|
||||
if (imgElement && imgElement instanceof HTMLImageElement) {
|
||||
//console.log("setting value");
|
||||
imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
|
||||
}
|
||||
|
||||
} else {
|
||||
//console.log("The fetch failed!");
|
||||
const url = new URL(getApiUrl());
|
||||
url.pathname = '/api/talkinghead/set_emotion';
|
||||
await doExtrasFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ emotion_name: expression }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
catch (error) {
|
||||
// `set_emotion` is not present in old versions, so let it 404.
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the <img> element with id="expression-image" and class="expression"
|
||||
const imgElement = document.querySelector('img#expression-image.expression');
|
||||
//console.log("searching");
|
||||
if (imgElement && imgElement instanceof HTMLImageElement) {
|
||||
//console.log("setting value");
|
||||
imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
//console.log("The fetch failed!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1245,6 +1407,11 @@ async function onClickExpressionUpload(event) {
|
||||
|
||||
// Reset the input
|
||||
e.target.form.reset();
|
||||
|
||||
// In talkinghead mode, when a new talkinghead image is uploaded, refresh the live char.
|
||||
if (isTalkingHeadEnabled() && id === 'talkinghead') {
|
||||
await loadTalkingHead();
|
||||
}
|
||||
};
|
||||
|
||||
$('#expression_upload')
|
||||
@@ -1471,11 +1638,17 @@ function setExpressionOverrideHtml(forceClear = false) {
|
||||
const updateFunction = wrapper.update.bind(wrapper);
|
||||
setInterval(updateFunction, UPDATE_INTERVAL);
|
||||
moduleWorker();
|
||||
// For setting the talkinghead talking animation on/off quickly enough for realtime use, we need another timer on a shorter schedule.
|
||||
const wrapperTalkingState = new ModuleWorkerWrapper(updateTalkingState);
|
||||
const updateTalkingStateFunction = wrapperTalkingState.update.bind(wrapperTalkingState);
|
||||
setInterval(updateTalkingStateFunction, TALKINGCHECK_UPDATE_INTERVAL);
|
||||
updateTalkingState();
|
||||
dragElement($('#expression-holder'));
|
||||
eventSource.on(event_types.CHAT_CHANGED, () => {
|
||||
// character changed
|
||||
removeExpression();
|
||||
spriteCache = {};
|
||||
lastExpression = {};
|
||||
|
||||
//clear expression
|
||||
let imgElement = document.getElementById('expression-image');
|
||||
@@ -1501,4 +1674,5 @@ function setExpressionOverrideHtml(forceClear = false) {
|
||||
eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
|
||||
registerSlashCommand('sprite', setSpriteSlashCommand, ['emote'], '<span class="monospace">(spriteId)</span> – force sets the sprite for the current character', true, true);
|
||||
registerSlashCommand('spriteoverride', setSpriteSetCommand, ['costume'], '<span class="monospace">(optional folder)</span> – sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.', true, true);
|
||||
registerSlashCommand('lastsprite', (_, value) => lastExpression[value.trim()] ?? '', [], '<span class="monospace">(charName)</span> – Returns the last set sprite / expression for the named character.', true, true);
|
||||
})();
|
||||
|
@@ -4,7 +4,7 @@ import {
|
||||
characters,
|
||||
getRequestHeaders,
|
||||
} from '../../../script.js';
|
||||
import { selected_group } from '../../group-chats.js';
|
||||
import { groups, selected_group } from '../../group-chats.js';
|
||||
import { loadFileToDocument, delay } from '../../utils.js';
|
||||
import { loadMovingUIState } from '../../power-user.js';
|
||||
import { dragElement } from '../../RossAscends-mods.js';
|
||||
@@ -416,7 +416,26 @@ function viewWithDragbox(items) {
|
||||
|
||||
// Registers a simple command for opening the char gallery.
|
||||
registerSlashCommand('show-gallery', showGalleryCommand, ['sg'], '– shows the gallery', true, true);
|
||||
registerSlashCommand('list-gallery', listGalleryCommand, ['lg'], '<span class="monospace">[optional char=charName] [optional group=groupName]</span> – list images in the gallery of the current char / group or a specified char / group', true, true);
|
||||
|
||||
function showGalleryCommand(args) {
|
||||
showCharGallery();
|
||||
}
|
||||
|
||||
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) {
|
||||
const char = characters[this_chid];
|
||||
url = char.avatar.replace('.png', '');
|
||||
}
|
||||
|
||||
const items = await getGalleryItems(url);
|
||||
return JSON.stringify(items.map(it=>it.src));
|
||||
|
||||
} catch (err) {
|
||||
console.trace();
|
||||
console.error(err);
|
||||
}
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
|
451
public/scripts/extensions/quick-reply/api/QuickReplyApi.js
Normal file
451
public/scripts/extensions/quick-reply/api/QuickReplyApi.js
Normal file
@@ -0,0 +1,451 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { QuickReply } from '../src/QuickReply.js';
|
||||
import { QuickReplyContextLink } from '../src/QuickReplyContextLink.js';
|
||||
import { QuickReplySet } from '../src/QuickReplySet.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { QuickReplySettings } from '../src/QuickReplySettings.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { SettingsUi } from '../src/ui/SettingsUi.js';
|
||||
|
||||
export class QuickReplyApi {
|
||||
/**@type {QuickReplySettings}*/ settings;
|
||||
/**@type {SettingsUi}*/ settingsUi;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {QuickReplySettings}*/settings, /**@type {SettingsUi}*/settingsUi) {
|
||||
this.settings = settings;
|
||||
this.settingsUi = settingsUi;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 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} label label of the quick reply
|
||||
* @returns the quick reply, or undefined if not found
|
||||
*/
|
||||
getQrByLabel(setName, label) {
|
||||
const set = this.getSetByName(setName);
|
||||
if (!set) return;
|
||||
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} label label of the existing quick reply (text on the button)
|
||||
* @param {Object} [args] optional arguments
|
||||
*/
|
||||
async executeQuickReply(setName, label, args = {}) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.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
|
||||
* @returns {QuickReply} the new quick reply
|
||||
*/
|
||||
createQuickReply(setName, label, {
|
||||
message,
|
||||
title,
|
||||
isHidden,
|
||||
executeOnStartup,
|
||||
executeOnUser,
|
||||
executeOnAi,
|
||||
executeOnChatChange,
|
||||
} = {}) {
|
||||
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.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.onUpdate();
|
||||
return qr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing quick reply.
|
||||
*
|
||||
* @param {String} setName name of the existing quick reply set
|
||||
* @param {String} label label of the existing quick reply (text on the button)
|
||||
* @param {Object} [props]
|
||||
* @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
|
||||
* @returns {QuickReply} the altered quick reply
|
||||
*/
|
||||
updateQuickReply(setName, label, {
|
||||
newLabel,
|
||||
message,
|
||||
title,
|
||||
isHidden,
|
||||
executeOnStartup,
|
||||
executeOnUser,
|
||||
executeOnAi,
|
||||
executeOnChatChange,
|
||||
} = {}) {
|
||||
const qr = this.getQrByLabel(setName, label);
|
||||
if (!qr) {
|
||||
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
|
||||
}
|
||||
qr.label = newLabel ?? qr.label;
|
||||
qr.message = message ?? qr.message;
|
||||
qr.title = 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.onUpdate();
|
||||
return qr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an existing quick reply.
|
||||
*
|
||||
* @param {String} setName name of the existing quick reply set
|
||||
* @param {String} label label of the existing quick reply (text on the button)
|
||||
*/
|
||||
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} label label of the existing quick reply
|
||||
* @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} label label of the existing quick reply
|
||||
* @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} label label of the existing quick reply
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
<div id="quickReply_contextMenuEditor_template">
|
||||
<div class="quickReply_contextMenuEditor">
|
||||
<h3><strong>Context Menu Editor</strong></h3>
|
||||
<div id="quickReply_contextMenuEditor_content">
|
||||
<template id="quickReply_contextMenuEditor_itemTemplate">
|
||||
<div class="quickReplyContextMenuEditor_item flex-container alignitemscenter" data-order="0">
|
||||
<span class="drag-handle ui-sortable-handle">☰</span>
|
||||
<select class="quickReply_contextMenuEditor_preset"></select>
|
||||
<label class="flex-container" title="When enabled, the current Quick Reply will be sent together with (before) the clicked QR from the context menu.">
|
||||
Chaining:
|
||||
<input type="checkbox" class="quickReply_contextMenuEditor_chaining">
|
||||
</label>
|
||||
<span class="quickReply_contextMenuEditor_remove menu_button menu_button_icon fa-solid fa-trash-can" title="Remove entry"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="quickReply_contextMenuEditor_actions">
|
||||
<span id="quickReply_contextMenuEditor_addPreset" class="menu_button menu_button_icon fa-solid fa-plus" title="Add preset to context menu"></span>
|
||||
</div>
|
||||
<h3><strong>Auto-Execute</strong></h3>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<label class="checkbox_label" for="quickReply_hidden">
|
||||
<input type="checkbox" id="quickReply_hidden" >
|
||||
<span><i class="fa-solid fa-fw fa-eye-slash"></i> Invisible (auto-execute only)</span>
|
||||
</label>
|
||||
<label class="checkbox_label" for="quickReply_autoExecute_appStartup">
|
||||
<input type="checkbox" id="quickReply_autoExecute_appStartup" >
|
||||
<span><i class="fa-solid fa-fw fa-rocket"></i> Execute on app startup</span>
|
||||
</label>
|
||||
<label class="checkbox_label" for="quickReply_autoExecute_userMessage">
|
||||
<input type="checkbox" id="quickReply_autoExecute_userMessage" >
|
||||
<span><i class="fa-solid fa-fw fa-user"></i> Execute on user message</span>
|
||||
</label>
|
||||
<label class="checkbox_label" for="quickReply_autoExecute_botMessage">
|
||||
<input type="checkbox" id="quickReply_autoExecute_botMessage" >
|
||||
<span><i class="fa-solid fa-fw fa-robot"></i> Execute on AI message</span>
|
||||
</label>
|
||||
<label class="checkbox_label" for="quickReply_autoExecute_chatLoad">
|
||||
<input type="checkbox" id="quickReply_autoExecute_chatLoad" >
|
||||
<span><i class="fa-solid fa-fw fa-message"></i> Execute on opening chat</span>
|
||||
</label>
|
||||
</div>
|
||||
<h3><strong>UI Options</strong></h3>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<label for="quickReply_ui_title">Title (tooltip, leave empty to show the message or /command)</label>
|
||||
<input type="text" class="text_pole" id="quickReply_ui_title">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
83
public/scripts/extensions/quick-reply/html/qrEditor.html
Normal file
83
public/scripts/extensions/quick-reply/html/qrEditor.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<div id="qr--modalEditor">
|
||||
<div id="qr--main">
|
||||
<h3>Labels and Message</h3>
|
||||
<div class="qr--labels">
|
||||
<label>
|
||||
<span class="qr--labelText">Label</span>
|
||||
<input type="text" class="text_pole" id="qr--modal-label">
|
||||
</label>
|
||||
<label>
|
||||
<span class="qr--labelText">Title</span>
|
||||
<small class="qr--labelHint">(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">Message / Command:</label>
|
||||
<textarea class="monospace" id="qr--modal-message"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="qr--qrOptions">
|
||||
<h3>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.">
|
||||
Chaining:
|
||||
<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>Auto-Execute</h3>
|
||||
<div 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> Don't trigger auto-execute</span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--isHidden" >
|
||||
<span><i class="fa-solid fa-fw fa-eye-slash"></i> Invisible (auto-execute only)</span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnStartup" >
|
||||
<span><i class="fa-solid fa-fw fa-rocket"></i> Execute on app startup</span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnUser" >
|
||||
<span><i class="fa-solid fa-fw fa-user"></i> Execute on user message</span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnAi" >
|
||||
<span><i class="fa-solid fa-fw fa-robot"></i> Execute on AI message</span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnChatChange" >
|
||||
<span><i class="fa-solid fa-fw fa-message"></i> Execute on opening chat</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<h3>Testing</h3>
|
||||
<div id="qr--modal-execute" class="menu_button" title="Execute the quick reply now">
|
||||
<i class="fa-solid fa-play"></i>
|
||||
Execute
|
||||
</div>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--modal-executeHide">
|
||||
<span> Hide editor while executing</span>
|
||||
</label>
|
||||
<div id="qr--modal-executeErrors"></div>
|
||||
</div>
|
||||
</div>
|
71
public/scripts/extensions/quick-reply/html/settings.html
Normal file
71
public/scripts/extensions/quick-reply/html/settings.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<div id="qr--settings">
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<strong>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"> Enable Quick Replies
|
||||
</label>
|
||||
<label class="flex-container">
|
||||
<input type="checkbox" id="qr--isCombined"> Combine buttons from all active sets
|
||||
</label>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="qr--global">
|
||||
<div class="qr--head">
|
||||
<div class="qr--title">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">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--editor">
|
||||
<div class="qr--head">
|
||||
<div class="qr--title">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-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--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>Disable send (insert into input field)</span>
|
||||
</label>
|
||||
<label class="flex-container">
|
||||
<input type="checkbox" id="qr--placeBeforeInput"> <span>Place quick reply before input</span>
|
||||
</label>
|
||||
<label class="flex-container" id="qr--injectInputContainer">
|
||||
<input type="checkbox" id="qr--injectInput"> <span>Inject user input automatically <small>(if disabled, use <code>{{input}}</code> macro for manual injection)</small></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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,6 @@
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "RossAscends#1779",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,76 @@
|
||||
import { warn } from '../index.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { QuickReply } from './QuickReply.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async handleStartup() {
|
||||
if (!this.checkExecute()) return;
|
||||
const qrList = [
|
||||
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnStartup)).flat(),
|
||||
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnStartup))?.flat() ?? []),
|
||||
];
|
||||
await this.performAutoExecute(qrList);
|
||||
}
|
||||
|
||||
async handleUser() {
|
||||
if (!this.checkExecute()) return;
|
||||
const qrList = [
|
||||
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnUser)).flat(),
|
||||
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnUser))?.flat() ?? []),
|
||||
];
|
||||
await this.performAutoExecute(qrList);
|
||||
}
|
||||
|
||||
async handleAi() {
|
||||
if (!this.checkExecute()) return;
|
||||
const qrList = [
|
||||
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnAi)).flat(),
|
||||
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnAi))?.flat() ?? []),
|
||||
];
|
||||
await this.performAutoExecute(qrList);
|
||||
}
|
||||
|
||||
async handleChatChanged() {
|
||||
if (!this.checkExecute()) return;
|
||||
const qrList = [
|
||||
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnChatChange)).flat(),
|
||||
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnChatChange))?.flat() ?? []),
|
||||
];
|
||||
await this.performAutoExecute(qrList);
|
||||
}
|
||||
}
|
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* @typedef {import('./MenuItem.js').MenuItem} MenuItem
|
||||
*/
|
||||
|
||||
export class ContextMenu {
|
||||
/**@type {MenuItem[]}*/ itemList = [];
|
||||
/**@type {Boolean}*/ isActive = false;
|
||||
|
||||
/**@type {HTMLElement}*/ root;
|
||||
/**@type {HTMLElement}*/ menu;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {MenuItem[]}*/items) {
|
||||
this.itemList = items;
|
||||
items.forEach(item => {
|
||||
item.onExpand = () => {
|
||||
items.filter(it => it != item)
|
||||
.forEach(it => it.collapse());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.root) {
|
||||
const blocker = document.createElement('div'); {
|
||||
this.root = blocker;
|
||||
blocker.classList.add('ctx-blocker');
|
||||
blocker.addEventListener('click', () => this.hide());
|
||||
const menu = document.createElement('ul'); {
|
||||
this.menu = menu;
|
||||
menu.classList.add('list-group');
|
||||
menu.classList.add('ctx-menu');
|
||||
this.itemList.forEach(it => menu.append(it.render()));
|
||||
blocker.append(menu);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.root;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
show({ clientX, clientY }) {
|
||||
if (this.isActive) return;
|
||||
this.isActive = true;
|
||||
this.render();
|
||||
this.menu.style.bottom = `${window.innerHeight - clientY}px`;
|
||||
this.menu.style.left = `${clientX}px`;
|
||||
document.body.append(this.root);
|
||||
}
|
||||
hide() {
|
||||
if (this.root) {
|
||||
this.root.remove();
|
||||
}
|
||||
this.isActive = false;
|
||||
}
|
||||
toggle(/**@type {PointerEvent}*/evt) {
|
||||
if (this.isActive) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show(evt);
|
||||
}
|
||||
}
|
||||
}
|
489
public/scripts/extensions/quick-reply/src/QuickReply.js
Normal file
489
public/scripts/extensions/quick-reply/src/QuickReply.js
Normal file
@@ -0,0 +1,489 @@
|
||||
import { callPopup } from '../../../../script.js';
|
||||
import { getSortableDelay } from '../../../utils.js';
|
||||
import { log, warn } from '../index.js';
|
||||
import { QuickReplyContextLink } from './QuickReplyContextLink.js';
|
||||
import { QuickReplySet } from './QuickReplySet.js';
|
||||
import { ContextMenu } from './ui/ctx/ContextMenu.js';
|
||||
|
||||
export class QuickReply {
|
||||
/**
|
||||
* @param {{ id?: number; contextList?: any; }} props
|
||||
*/
|
||||
static from(props) {
|
||||
props.contextList = (props.contextList ?? []).map((/** @type {any} */ it)=>QuickReplyContextLink.from(it));
|
||||
return Object.assign(new this(), props);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {Number}*/ id;
|
||||
/**@type {String}*/ label = '';
|
||||
/**@type {String}*/ title = '';
|
||||
/**@type {String}*/ message = '';
|
||||
|
||||
/**@type {QuickReplyContextLink[]}*/ contextList;
|
||||
|
||||
/**@type {Boolean}*/ preventAutoExecute = true;
|
||||
/**@type {Boolean}*/ isHidden = false;
|
||||
/**@type {Boolean}*/ executeOnStartup = false;
|
||||
/**@type {Boolean}*/ executeOnUser = false;
|
||||
/**@type {Boolean}*/ executeOnAi = false;
|
||||
/**@type {Boolean}*/ executeOnChatChange = false;
|
||||
|
||||
/**@type {Function}*/ onExecute;
|
||||
/**@type {Function}*/ onDelete;
|
||||
/**@type {Function}*/ onUpdate;
|
||||
|
||||
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ domLabel;
|
||||
/**@type {HTMLElement}*/ settingsDom;
|
||||
/**@type {HTMLInputElement}*/ settingsDomLabel;
|
||||
/**@type {HTMLTextAreaElement}*/ settingsDomMessage;
|
||||
|
||||
|
||||
get hasContext() {
|
||||
return this.contextList && this.contextList.length > 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
unrender() {
|
||||
this.dom?.remove();
|
||||
this.dom = null;
|
||||
}
|
||||
updateRender() {
|
||||
if (!this.dom) return;
|
||||
this.dom.title = this.title || this.message;
|
||||
this.domLabel.textContent = this.label;
|
||||
this.dom.classList[this.hasContext ? 'add' : 'remove']('qr--hasCtx');
|
||||
}
|
||||
render() {
|
||||
this.unrender();
|
||||
if (!this.dom) {
|
||||
const root = document.createElement('div'); {
|
||||
this.dom = root;
|
||||
root.classList.add('qr--button');
|
||||
root.classList.add('menu_button');
|
||||
if (this.hasContext) {
|
||||
root.classList.add('qr--hasCtx');
|
||||
}
|
||||
root.title = this.title || this.message;
|
||||
root.addEventListener('contextmenu', (evt) => {
|
||||
log('contextmenu', this, this.hasContext);
|
||||
if (this.hasContext) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const menu = new ContextMenu(this);
|
||||
menu.show(evt);
|
||||
}
|
||||
});
|
||||
root.addEventListener('click', (evt)=>{
|
||||
if (evt.ctrlKey) {
|
||||
this.showEditor();
|
||||
return;
|
||||
}
|
||||
this.execute();
|
||||
});
|
||||
const lbl = document.createElement('div'); {
|
||||
this.domLabel = lbl;
|
||||
lbl.classList.add('qr--button-label');
|
||||
lbl.textContent = this.label;
|
||||
root.append(lbl);
|
||||
}
|
||||
const expander = document.createElement('div'); {
|
||||
expander.classList.add('qr--button-expander');
|
||||
expander.textContent = '⋮';
|
||||
expander.title = 'Open context menu';
|
||||
expander.addEventListener('click', (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
const menu = new ContextMenu(this);
|
||||
menu.show(evt);
|
||||
});
|
||||
root.append(expander);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
renderSettings(idx) {
|
||||
if (!this.settingsDom) {
|
||||
const item = document.createElement('div'); {
|
||||
this.settingsDom = item;
|
||||
item.classList.add('qr--set-item');
|
||||
item.setAttribute('data-order', String(idx));
|
||||
item.setAttribute('data-id', String(this.id));
|
||||
const drag = document.createElement('div'); {
|
||||
drag.classList.add('drag-handle');
|
||||
drag.classList.add('ui-sortable-handle');
|
||||
drag.textContent = '☰';
|
||||
item.append(drag);
|
||||
}
|
||||
const lblContainer = document.createElement('div'); {
|
||||
lblContainer.classList.add('qr--set-itemLabelContainer');
|
||||
const lbl = document.createElement('input'); {
|
||||
this.settingsDomLabel = lbl;
|
||||
lbl.classList.add('qr--set-itemLabel');
|
||||
lbl.classList.add('text_pole');
|
||||
lbl.value = this.label;
|
||||
lbl.addEventListener('input', ()=>this.updateLabel(lbl.value));
|
||||
lblContainer.append(lbl);
|
||||
}
|
||||
item.append(lblContainer);
|
||||
}
|
||||
const optContainer = document.createElement('div'); {
|
||||
optContainer.classList.add('qr--set-optionsContainer');
|
||||
const opt = document.createElement('div'); {
|
||||
opt.classList.add('qr--action');
|
||||
opt.classList.add('menu_button');
|
||||
opt.classList.add('fa-solid');
|
||||
opt.textContent = '⁝';
|
||||
opt.title = 'Additional options:\n - large editor\n - context menu\n - auto-execution\n - tooltip';
|
||||
opt.addEventListener('click', ()=>this.showEditor());
|
||||
optContainer.append(opt);
|
||||
}
|
||||
item.append(optContainer);
|
||||
}
|
||||
const mes = document.createElement('textarea'); {
|
||||
this.settingsDomMessage = mes;
|
||||
mes.id = `qr--set--item${this.id}`;
|
||||
mes.classList.add('qr--set-itemMessage');
|
||||
mes.value = this.message;
|
||||
//HACK need to use jQuery to catch the triggered event from the expanded editor
|
||||
$(mes).on('input', ()=>this.updateMessage(mes.value));
|
||||
item.append(mes);
|
||||
}
|
||||
const actions = document.createElement('div'); {
|
||||
actions.classList.add('qr--actions');
|
||||
const del = document.createElement('div'); {
|
||||
del.classList.add('qr--action');
|
||||
del.classList.add('menu_button');
|
||||
del.classList.add('menu_button_icon');
|
||||
del.classList.add('fa-solid');
|
||||
del.classList.add('fa-trash-can');
|
||||
del.classList.add('redWarningBG');
|
||||
del.title = 'Remove quick reply';
|
||||
del.addEventListener('click', ()=>this.delete());
|
||||
actions.append(del);
|
||||
}
|
||||
item.append(actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.settingsDom;
|
||||
}
|
||||
unrenderSettings() {
|
||||
this.settingsDom?.remove();
|
||||
}
|
||||
|
||||
async showEditor() {
|
||||
const response = await fetch('/scripts/extensions/quick-reply/html/qrEditor.html', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--modalEditor');
|
||||
/**@type {HTMLElement} */
|
||||
// @ts-ignore
|
||||
const dom = this.template.cloneNode(true);
|
||||
const popupResult = callPopup(dom, 'text', undefined, { okButton: 'OK', wide: true, large: true, rows: 1 });
|
||||
|
||||
// basics
|
||||
/**@type {HTMLInputElement}*/
|
||||
const label = dom.querySelector('#qr--modal-label');
|
||||
label.value = this.label;
|
||||
label.addEventListener('input', ()=>{
|
||||
this.updateLabel(label.value);
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const title = dom.querySelector('#qr--modal-title');
|
||||
title.value = this.title;
|
||||
title.addEventListener('input', () => {
|
||||
this.updateTitle(title.value);
|
||||
});
|
||||
/**@type {HTMLTextAreaElement}*/
|
||||
const message = dom.querySelector('#qr--modal-message');
|
||||
message.value = this.message;
|
||||
message.addEventListener('input', () => {
|
||||
this.updateMessage(message.value);
|
||||
});
|
||||
//TODO move tab support for textarea into its own helper(?) and use for both this and .editor_maximize
|
||||
message.addEventListener('keydown', (evt) => {
|
||||
if (evt.key == 'Tab' && !evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
|
||||
evt.preventDefault();
|
||||
const start = message.selectionStart;
|
||||
const end = message.selectionEnd;
|
||||
if (end - start > 0 && message.value.substring(start, end).includes('\n')) {
|
||||
const lineStart = message.value.lastIndexOf('\n', start);
|
||||
const count = message.value.substring(lineStart, end).split('\n').length - 1;
|
||||
message.value = `${message.value.substring(0, lineStart)}${message.value.substring(lineStart, end).replace(/\n/g, '\n\t')}${message.value.substring(end)}`;
|
||||
message.selectionStart = start + 1;
|
||||
message.selectionEnd = end + count;
|
||||
this.updateMessage(message.value);
|
||||
} else {
|
||||
message.value = `${message.value.substring(0, start)}\t${message.value.substring(end)}`;
|
||||
message.selectionStart = start + 1;
|
||||
message.selectionEnd = end + 1;
|
||||
this.updateMessage(message.value);
|
||||
}
|
||||
} else if (evt.key == 'Tab' && evt.shiftKey && !evt.ctrlKey && !evt.altKey) {
|
||||
evt.preventDefault();
|
||||
const start = message.selectionStart;
|
||||
const end = message.selectionEnd;
|
||||
const lineStart = message.value.lastIndexOf('\n', start);
|
||||
const count = message.value.substring(lineStart, end).split('\n\t').length - 1;
|
||||
message.value = `${message.value.substring(0, lineStart)}${message.value.substring(lineStart, end).replace(/\n\t/g, '\n')}${message.value.substring(end)}`;
|
||||
message.selectionStart = start - 1;
|
||||
message.selectionEnd = end - count;
|
||||
this.updateMessage(message.value);
|
||||
}
|
||||
});
|
||||
|
||||
// context menu
|
||||
/**@type {HTMLTemplateElement}*/
|
||||
const tpl = dom.querySelector('#qr--ctxItem');
|
||||
const linkList = dom.querySelector('#qr--ctxEditor');
|
||||
const fillQrSetSelect = (/**@type {HTMLSelectElement}*/select, /**@type {QuickReplyContextLink}*/ link) => {
|
||||
[{ name: 'Select a QR set' }, ...QuickReplySet.list].forEach(qrs => {
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
opt.selected = qrs.name == link.set?.name;
|
||||
select.append(opt);
|
||||
}
|
||||
});
|
||||
};
|
||||
const addCtxItem = (/**@type {QuickReplyContextLink}*/link, /**@type {Number}*/idx) => {
|
||||
/**@type {HTMLElement} */
|
||||
// @ts-ignore
|
||||
const itemDom = tpl.content.querySelector('.qr--ctxItem').cloneNode(true); {
|
||||
itemDom.setAttribute('data-order', String(idx));
|
||||
|
||||
/**@type {HTMLSelectElement} */
|
||||
const select = itemDom.querySelector('.qr--set');
|
||||
fillQrSetSelect(select, link);
|
||||
select.addEventListener('change', () => {
|
||||
link.set = QuickReplySet.get(select.value);
|
||||
this.updateContext();
|
||||
});
|
||||
|
||||
/**@type {HTMLInputElement} */
|
||||
const chain = itemDom.querySelector('.qr--isChained');
|
||||
chain.checked = link.isChained;
|
||||
chain.addEventListener('click', () => {
|
||||
link.isChained = chain.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
|
||||
itemDom.querySelector('.qr--delete').addEventListener('click', () => {
|
||||
itemDom.remove();
|
||||
this.contextList.splice(this.contextList.indexOf(link), 1);
|
||||
this.updateContext();
|
||||
});
|
||||
|
||||
linkList.append(itemDom);
|
||||
}
|
||||
};
|
||||
[...this.contextList].forEach((link, idx) => addCtxItem(link, idx));
|
||||
dom.querySelector('#qr--ctxAdd').addEventListener('click', () => {
|
||||
const link = new QuickReplyContextLink();
|
||||
this.contextList.push(link);
|
||||
addCtxItem(link, this.contextList.length - 1);
|
||||
});
|
||||
const onContextSort = () => {
|
||||
this.contextList = Array.from(linkList.querySelectorAll('.qr--ctxItem')).map((it,idx) => {
|
||||
const link = this.contextList[Number(it.getAttribute('data-order'))];
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return link;
|
||||
});
|
||||
this.updateContext();
|
||||
};
|
||||
// @ts-ignore
|
||||
$(linkList).sortable({
|
||||
delay: getSortableDelay(),
|
||||
stop: () => onContextSort(),
|
||||
});
|
||||
|
||||
// auto-exec
|
||||
/**@type {HTMLInputElement}*/
|
||||
const preventAutoExecute = dom.querySelector('#qr--preventAutoExecute');
|
||||
preventAutoExecute.checked = this.preventAutoExecute;
|
||||
preventAutoExecute.addEventListener('click', ()=>{
|
||||
this.preventAutoExecute = preventAutoExecute.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const isHidden = dom.querySelector('#qr--isHidden');
|
||||
isHidden.checked = this.isHidden;
|
||||
isHidden.addEventListener('click', ()=>{
|
||||
this.isHidden = isHidden.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeOnStartup = dom.querySelector('#qr--executeOnStartup');
|
||||
executeOnStartup.checked = this.executeOnStartup;
|
||||
executeOnStartup.addEventListener('click', ()=>{
|
||||
this.executeOnStartup = executeOnStartup.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeOnUser = dom.querySelector('#qr--executeOnUser');
|
||||
executeOnUser.checked = this.executeOnUser;
|
||||
executeOnUser.addEventListener('click', ()=>{
|
||||
this.executeOnUser = executeOnUser.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeOnAi = dom.querySelector('#qr--executeOnAi');
|
||||
executeOnAi.checked = this.executeOnAi;
|
||||
executeOnAi.addEventListener('click', ()=>{
|
||||
this.executeOnAi = executeOnAi.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeOnChatChange = dom.querySelector('#qr--executeOnChatChange');
|
||||
executeOnChatChange.checked = this.executeOnChatChange;
|
||||
executeOnChatChange.addEventListener('click', ()=>{
|
||||
this.executeOnChatChange = executeOnChatChange.checked;
|
||||
this.updateContext();
|
||||
});
|
||||
|
||||
|
||||
/**@type {HTMLElement}*/
|
||||
const executeErrors = dom.querySelector('#qr--modal-executeErrors');
|
||||
/**@type {HTMLInputElement}*/
|
||||
const executeHide = dom.querySelector('#qr--modal-executeHide');
|
||||
let executePromise;
|
||||
/**@type {HTMLElement}*/
|
||||
const executeBtn = dom.querySelector('#qr--modal-execute');
|
||||
executeBtn.addEventListener('click', async()=>{
|
||||
if (executePromise) return;
|
||||
executeBtn.classList.add('qr--busy');
|
||||
executeErrors.innerHTML = '';
|
||||
if (executeHide.checked) {
|
||||
document.querySelector('#shadow_popup').classList.add('qr--hide');
|
||||
}
|
||||
try {
|
||||
executePromise = this.execute();
|
||||
await executePromise;
|
||||
} catch (ex) {
|
||||
executeErrors.textContent = ex.message;
|
||||
}
|
||||
executePromise = null;
|
||||
executeBtn.classList.remove('qr--busy');
|
||||
document.querySelector('#shadow_popup').classList.remove('qr--hide');
|
||||
});
|
||||
|
||||
await popupResult;
|
||||
} else {
|
||||
warn('failed to fetch qrEditor template');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
delete() {
|
||||
if (this.onDelete) {
|
||||
this.unrender();
|
||||
this.unrenderSettings();
|
||||
this.onDelete(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
updateMessage(value) {
|
||||
if (this.onUpdate) {
|
||||
if (this.settingsDomMessage && this.settingsDomMessage.value != value) {
|
||||
this.settingsDomMessage.value = value;
|
||||
}
|
||||
this.message = value;
|
||||
this.updateRender();
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
updateLabel(value) {
|
||||
if (this.onUpdate) {
|
||||
if (this.settingsDomLabel && this.settingsDomLabel.value != value) {
|
||||
this.settingsDomLabel.value = value;
|
||||
}
|
||||
this.label = value;
|
||||
this.updateRender();
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
updateTitle(value) {
|
||||
if (this.onUpdate) {
|
||||
this.title = value;
|
||||
this.updateRender();
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
|
||||
updateContext() {
|
||||
if (this.onUpdate) {
|
||||
this.updateRender();
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
addContextLink(cl) {
|
||||
this.contextList.push(cl);
|
||||
this.updateContext();
|
||||
}
|
||||
removeContextLink(setName) {
|
||||
const idx = this.contextList.findIndex(it=>it.set.name == setName);
|
||||
if (idx > -1) {
|
||||
this.contextList.splice(idx, 1);
|
||||
this.updateContext();
|
||||
}
|
||||
}
|
||||
clearContextLinks() {
|
||||
if (this.contextList.length) {
|
||||
this.contextList.splice(0, this.contextList.length);
|
||||
this.updateContext();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async execute(args = {}) {
|
||||
if (this.message?.length > 0 && this.onExecute) {
|
||||
const message = this.message.replace(/\{\{arg::([^}]+)\}\}/g, (_, key) => {
|
||||
return args[key] ?? '';
|
||||
});
|
||||
return await this.onExecute(this, message, args.isAutoExecute ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
label: this.label,
|
||||
title: this.title,
|
||||
message: this.message,
|
||||
contextList: this.contextList,
|
||||
preventAutoExecute: this.preventAutoExecute,
|
||||
isHidden: this.isHidden,
|
||||
executeOnStartup: this.executeOnStartup,
|
||||
executeOnUser: this.executeOnUser,
|
||||
executeOnAi: this.executeOnAi,
|
||||
executeOnChatChange: this.executeOnChatChange,
|
||||
};
|
||||
}
|
||||
}
|
122
public/scripts/extensions/quick-reply/src/QuickReplyConfig.js
Normal file
122
public/scripts/extensions/quick-reply/src/QuickReplyConfig.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { getSortableDelay } from '../../../utils.js';
|
||||
import { QuickReplySetLink } from './QuickReplySetLink.js';
|
||||
import { QuickReplySet } from './QuickReplySet.js';
|
||||
|
||||
export class QuickReplyConfig {
|
||||
/**@type {QuickReplySetLink[]}*/ setList = [];
|
||||
/**@type {Boolean}*/ isGlobal;
|
||||
|
||||
/**@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', ()=>{
|
||||
this.addSet(QuickReplySet.list[0]);
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
209
public/scripts/extensions/quick-reply/src/QuickReplySet.js
Normal file
209
public/scripts/extensions/quick-reply/src/QuickReplySet.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { getRequestHeaders, substituteParams } from '../../../../script.js';
|
||||
import { executeSlashCommands } from '../../../slash-commands.js';
|
||||
import { debounceAsync, warn } from '../index.js';
|
||||
import { QuickReply } from './QuickReply.js';
|
||||
|
||||
export class QuickReplySet {
|
||||
/**@type {QuickReplySet[]}*/ static list = [];
|
||||
|
||||
|
||||
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 {Boolean}*/ disableSend = false;
|
||||
/**@type {Boolean}*/ placeBeforeInput = false;
|
||||
/**@type {Boolean}*/ injectInput = 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.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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
renderSettingsItem(qr, idx) {
|
||||
this.settingsDom.append(qr.renderSettings(idx));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {QuickReply} qr
|
||||
* @param {String} [message] - optional altered message to be used
|
||||
*/
|
||||
async execute(qr, message = null, isAutoExecute = false) {
|
||||
/**@type {HTMLTextAreaElement}*/
|
||||
const ta = document.querySelector('#send_textarea');
|
||||
const finalMessage = message ?? qr.message;
|
||||
let input = ta.value;
|
||||
if (!isAutoExecute && this.injectInput && input.length > 0) {
|
||||
if (this.placeBeforeInput) {
|
||||
input = `${finalMessage} ${input}`;
|
||||
} else {
|
||||
input = `${input} ${finalMessage}`;
|
||||
}
|
||||
} else {
|
||||
input = `${finalMessage} `;
|
||||
}
|
||||
|
||||
if (input[0] == '/' && !this.disableSend) {
|
||||
const result = await executeSlashCommands(input);
|
||||
return typeof result === 'object' ? result?.pipe : '';
|
||||
}
|
||||
|
||||
ta.value = substituteParams(input);
|
||||
ta.focus();
|
||||
|
||||
if (!this.disableSend) {
|
||||
// @ts-ignore
|
||||
document.querySelector('#send_but').click();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
addQuickReply() {
|
||||
const id = Math.max(this.idIndex, this.qrList.reduce((max,qr)=>Math.max(max,qr.id),0)) + 1;
|
||||
this.idIndex = id + 1;
|
||||
const qr = QuickReply.from({ id });
|
||||
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;
|
||||
}
|
||||
|
||||
hookQuickReply(qr) {
|
||||
qr.onExecute = (_, message, isAutoExecute)=>this.execute(qr, message, isAutoExecute);
|
||||
qr.onDelete = ()=>this.removeQuickReply(qr);
|
||||
qr.onUpdate = ()=>this.save();
|
||||
}
|
||||
|
||||
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,
|
||||
qrList: this.qrList,
|
||||
idIndex: this.idIndex,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async performSave() {
|
||||
const response = await fetch('/savequickreply', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.rerender();
|
||||
} else {
|
||||
warn(`Failed to save Quick Reply Set: ${this.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const response = await fetch('/deletequickreply', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.unrender();
|
||||
const idx = QuickReplySet.list.indexOf(this);
|
||||
QuickReplySet.list.splice(idx, 1);
|
||||
this.isDeleted = true;
|
||||
} else {
|
||||
warn(`Failed to delete Quick Reply Set: ${this.name}`);
|
||||
}
|
||||
}
|
||||
}
|
129
public/scripts/extensions/quick-reply/src/QuickReplySetLink.js
Normal file
129
public/scripts/extensions/quick-reply/src/QuickReplySetLink.js
Normal 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.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,
|
||||
};
|
||||
}
|
||||
}
|
@@ -0,0 +1,85 @@
|
||||
import { chat_metadata, saveChatDebounced, saveSettingsDebounced } from '../../../../script.js';
|
||||
import { extension_settings } from '../../../extensions.js';
|
||||
import { QuickReplyConfig } from './QuickReplyConfig.js';
|
||||
|
||||
export class QuickReplySettings {
|
||||
static from(props) {
|
||||
props.config = QuickReplyConfig.from(props.config);
|
||||
const instance = Object.assign(new this(), props);
|
||||
instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {Boolean}*/ isEnabled = false;
|
||||
/**@type {Boolean}*/ isCombined = false;
|
||||
/**@type {Boolean}*/ isPopout = false;
|
||||
/**@type {QuickReplyConfig}*/ config;
|
||||
/**@type {QuickReplyConfig}*/ _chatConfig;
|
||||
get chatConfig() {
|
||||
return this._chatConfig;
|
||||
}
|
||||
set chatConfig(value) {
|
||||
if (this._chatConfig != value) {
|
||||
this.unhookConfig(this._chatConfig);
|
||||
this._chatConfig = value;
|
||||
this.hookConfig(this._chatConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**@type {Function}*/ onSave;
|
||||
/**@type {Function}*/ onRequestEditSet;
|
||||
|
||||
|
||||
|
||||
|
||||
init() {
|
||||
this.hookConfig(this.config);
|
||||
this.hookConfig(this.chatConfig);
|
||||
}
|
||||
|
||||
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();
|
||||
saveChatDebounced();
|
||||
}
|
||||
if (this.onSave) {
|
||||
this.onSave();
|
||||
}
|
||||
}
|
||||
|
||||
requestEditSet(qrs) {
|
||||
if (this.onRequestEditSet) {
|
||||
this.onRequestEditSet(qrs);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
isEnabled: this.isEnabled,
|
||||
isCombined: this.isCombined,
|
||||
isPopout: this.isPopout,
|
||||
config: this.config,
|
||||
};
|
||||
}
|
||||
}
|
270
public/scripts/extensions/quick-reply/src/SlashCommandHandler.js
Normal file
270
public/scripts/extensions/quick-reply/src/SlashCommandHandler.js
Normal file
@@ -0,0 +1,270 @@
|
||||
import { registerSlashCommand } from '../../../slash-commands.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { QuickReplyApi } from '../api/QuickReplyApi.js';
|
||||
|
||||
export class SlashCommandHandler {
|
||||
/**@type {QuickReplyApi}*/ api;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {QuickReplyApi}*/api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
init() {
|
||||
registerSlashCommand('qr', (_, value) => this.executeQuickReplyByIndex(Number(value)), [], '<span class="monospace">(number)</span> – activates the specified Quick Reply', true, true);
|
||||
registerSlashCommand('qrset', ()=>toastr.warning('The command /qrset has been deprecated. Use /qr-set, /qr-set-on, and /qr-set-off instead.'), [], '<strong>DEPRECATED</strong> – The command /qrset has been deprecated. Use /qr-set, /qr-set-on, and /qr-set-off instead.', true, true);
|
||||
registerSlashCommand('qr-set', (args, value)=>this.toggleGlobalSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> – toggle global QR set', true, true);
|
||||
registerSlashCommand('qr-set-on', (args, value)=>this.addGlobalSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> – activate global QR set', true, true);
|
||||
registerSlashCommand('qr-set-off', (_, value)=>this.removeGlobalSet(value), [], '<span class="monospace">(number)</span> – deactivate global QR set', true, true);
|
||||
registerSlashCommand('qr-chat-set', (args, value)=>this.toggleChatSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> – toggle chat QR set', true, true);
|
||||
registerSlashCommand('qr-chat-set-on', (args, value)=>this.addChatSet(value, args), [], '<span class="monospace">[visible=true] (number)</span> – activate chat QR set', true, true);
|
||||
registerSlashCommand('qr-chat-set-off', (_, value)=>this.removeChatSet(value), [], '<span class="monospace">(number)</span> – deactivate chat QR set', true, true);
|
||||
registerSlashCommand('qr-set-list', (_, value)=>this.listSets(value ?? 'all'), [], '(all|global|chat) – gets a list of the names of all quick reply sets', true, true);
|
||||
registerSlashCommand('qr-list', (_, value)=>this.listQuickReplies(value), [], '(set name) – gets a list of the names of all quick replies in this quick reply set', true, true);
|
||||
|
||||
const qrArgs = `
|
||||
label - string - text on the button, e.g., label=MyButton
|
||||
set - string - name of the QR set, e.g., set=PresetName1
|
||||
hidden - bool - whether the button should be hidden, e.g., hidden=true
|
||||
startup - bool - auto execute on app startup, e.g., startup=true
|
||||
user - bool - auto execute on user message, e.g., user=true
|
||||
bot - bool - auto execute on AI message, e.g., bot=true
|
||||
load - bool - auto execute on chat load, e.g., load=true
|
||||
title - bool - title / tooltip to be shown on button, e.g., title="My Fancy Button"
|
||||
`.trim();
|
||||
const qrUpdateArgs = `
|
||||
newlabel - string - new text for the button, e.g. newlabel=MyRenamedButton
|
||||
${qrArgs}
|
||||
`.trim();
|
||||
registerSlashCommand('qr-create', (args, message)=>this.createQuickReply(args, message), [], `<span class="monospace" style="white-space:pre-line;">[arguments] (message)\n arguments:\n ${qrArgs}</span> – creates a new Quick Reply, example: <tt>/qr-create set=MyPreset label=MyButton /echo 123</tt>`, true, true);
|
||||
registerSlashCommand('qr-update', (args, message)=>this.updateQuickReply(args, message), [], `<span class="monospace" style="white-space:pre-line;">[arguments] (message)\n arguments:\n ${qrUpdateArgs}</span> – updates Quick Reply, example: <tt>/qr-update set=MyPreset label=MyButton newlabel=MyRenamedButton /echo 123</tt>`, true, true);
|
||||
registerSlashCommand('qr-delete', (args, name)=>this.deleteQuickReply(args, name), [], '<span class="monospace">set=string [label]</span> – deletes Quick Reply', true, true);
|
||||
registerSlashCommand('qr-contextadd', (args, name)=>this.createContextItem(args, name), [], '<span class="monospace">set=string label=string [chain=false] (preset name)</span> – add context menu preset to a QR, example: <tt>/qr-contextadd set=MyPreset label=MyButton chain=true MyOtherPreset</tt>', true, true);
|
||||
registerSlashCommand('qr-contextdel', (args, name)=>this.deleteContextItem(args, name), [], '<span class="monospace">set=string label=string (preset name)</span> – remove context menu preset from a QR, example: <tt>/qr-contextdel set=MyPreset label=MyButton MyOtherPreset</tt>', true, true);
|
||||
registerSlashCommand('qr-contextclear', (args, label)=>this.clearContextMenu(args, label), [], '<span class="monospace">set=string (label)</span> – remove all context menu presets from a QR, example: <tt>/qr-contextclear set=MyPreset MyButton</tt>', true, true);
|
||||
const presetArgs = `
|
||||
nosend - bool - disable send / insert in user input (invalid for slash commands)
|
||||
before - bool - place QR before user input
|
||||
inject - bool - inject user input automatically (if disabled use {{input}})
|
||||
`.trim();
|
||||
registerSlashCommand('qr-set-create', (args, name)=>this.createSet(name, args), ['qr-presetadd'], `<span class="monospace" style="white-space:pre-line;">[arguments] (name)\n arguments:\n ${presetArgs}</span> – create a new preset (overrides existing ones), example: <tt>/qr-set-add MyNewPreset</tt>`, true, true);
|
||||
registerSlashCommand('qr-set-update', (args, name)=>this.updateSet(name, args), ['qr-presetupdate'], `<span class="monospace" style="white-space:pre-line;">[arguments] (name)\n arguments:\n ${presetArgs}</span> – update an existing preset, example: <tt>/qr-set-update enabled=false MyPreset</tt>`, true, true);
|
||||
registerSlashCommand('qr-set-delete', (args, name)=>this.deleteSet(name), ['qr-presetdelete'], `<span class="monospace" style="white-space:pre-line;">(name)\n arguments:\n ${presetArgs}</span> – delete an existing preset, example: <tt>/qr-set-delete MyPreset</tt>`, true, true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
getSetByName(name) {
|
||||
const set = this.api.getSetByName(name);
|
||||
if (!set) {
|
||||
toastr.error(`No Quick Reply Set with the name "${name}" could be found.`);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
getQrByLabel(setName, label) {
|
||||
const qr = this.api.getQrByLabel(setName, label);
|
||||
if (!qr) {
|
||||
toastr.error(`No Quick Reply with the label "${label}" could be found in the set "${setName}"`);
|
||||
}
|
||||
return qr;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async executeQuickReplyByIndex(idx) {
|
||||
try {
|
||||
return await this.api.executeQuickReplyByIndex(idx);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toggleGlobalSet(name, args = {}) {
|
||||
try {
|
||||
this.api.toggleGlobalSet(name, JSON.parse(args.visible ?? 'true') === true);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
addGlobalSet(name, args = {}) {
|
||||
try {
|
||||
this.api.addGlobalSet(name, JSON.parse(args.visible ?? 'true') === true);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
removeGlobalSet(name) {
|
||||
try {
|
||||
this.api.removeGlobalSet(name);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
toggleChatSet(name, args = {}) {
|
||||
try {
|
||||
this.api.toggleChatSet(name, JSON.parse(args.visible ?? 'true') === true);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
addChatSet(name, args = {}) {
|
||||
try {
|
||||
this.api.addChatSet(name, JSON.parse(args.visible ?? 'true') === true);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
removeChatSet(name) {
|
||||
try {
|
||||
this.api.removeChatSet(name);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createQuickReply(args, message) {
|
||||
try {
|
||||
this.api.createQuickReply(
|
||||
args.set ?? '',
|
||||
args.label ?? '',
|
||||
{
|
||||
message: message ?? '',
|
||||
title: args.title,
|
||||
isHidden: JSON.parse(args.hidden ?? 'false') === true,
|
||||
executeOnStartup: JSON.parse(args.startup ?? 'false') === true,
|
||||
executeOnUser: JSON.parse(args.user ?? 'false') === true,
|
||||
executeOnAi: JSON.parse(args.bot ?? 'false') === true,
|
||||
executeOnChatChange: JSON.parse(args.load ?? 'false') === true,
|
||||
},
|
||||
);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
updateQuickReply(args, message) {
|
||||
try {
|
||||
this.api.updateQuickReply(
|
||||
args.set ?? '',
|
||||
args.label ?? '',
|
||||
{
|
||||
newLabel: args.newlabel,
|
||||
message: (message ?? '').trim().length > 0 ? message : undefined,
|
||||
title: args.title,
|
||||
isHidden: args.hidden,
|
||||
executeOnStartup: args.startup,
|
||||
executeOnUser: args.user,
|
||||
executeOnAi: args.bot,
|
||||
executeOnChatChange: args.load,
|
||||
},
|
||||
);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
deleteQuickReply(args, label) {
|
||||
try {
|
||||
this.api.deleteQuickReply(args.set, label);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createContextItem(args, name) {
|
||||
try {
|
||||
this.api.createContextItem(
|
||||
args.set,
|
||||
args.label,
|
||||
name,
|
||||
JSON.parse(args.chain ?? 'false') === true,
|
||||
);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
deleteContextItem(args, name) {
|
||||
try {
|
||||
this.api.deleteContextItem(args.set, args.label, name);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
clearContextMenu(args, label) {
|
||||
try {
|
||||
this.api.clearContextMenu(args.set, args.label ?? label);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createSet(name, args) {
|
||||
try {
|
||||
this.api.createSet(
|
||||
args.name ?? name ?? '',
|
||||
{
|
||||
disableSend: JSON.parse(args.nosend ?? 'false') === true,
|
||||
placeBeforeInput: JSON.parse(args.before ?? 'false') === true,
|
||||
injectInput: JSON.parse(args.inject ?? 'false') === true,
|
||||
},
|
||||
);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
updateSet(name, args) {
|
||||
try {
|
||||
this.api.updateSet(
|
||||
args.name ?? name ?? '',
|
||||
{
|
||||
disableSend: args.nosend !== undefined ? JSON.parse(args.nosend ?? 'false') === true : undefined,
|
||||
placeBeforeInput: args.before !== undefined ? JSON.parse(args.before ?? 'false') === true : undefined,
|
||||
injectInput: args.inject !== undefined ? JSON.parse(args.inject ?? 'false') === true : undefined,
|
||||
},
|
||||
);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
deleteSet(name) {
|
||||
try {
|
||||
this.api.deleteSet(name ?? '');
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
|
||||
listSets(source) {
|
||||
try {
|
||||
switch (source) {
|
||||
case 'global':
|
||||
return this.api.listGlobalSets();
|
||||
case 'chat':
|
||||
return this.api.listChatSets();
|
||||
default:
|
||||
return this.api.listSets();
|
||||
}
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
listQuickReplies(name) {
|
||||
try {
|
||||
return this.api.listQuickReplies(name);
|
||||
} catch (ex) {
|
||||
toastr.error(ex.message);
|
||||
}
|
||||
}
|
||||
}
|
161
public/scripts/extensions/quick-reply/src/ui/ButtonUi.js
Normal file
161
public/scripts/extensions/quick-reply/src/ui/ButtonUi.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import { animation_duration } from '../../../../../script.js';
|
||||
import { dragElement } from '../../../../RossAscends-mods.js';
|
||||
import { loadMovingUIState } from '../../../../power-user.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
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');
|
||||
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 ?? [])]
|
||||
.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 ?? [])]
|
||||
.filter(link=>link.isVisible)
|
||||
.forEach(link=>buttonHolder.append(link.set.render()))
|
||||
;
|
||||
root.append(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.popoutDom;
|
||||
}
|
||||
}
|
366
public/scripts/extensions/quick-reply/src/ui/SettingsUi.js
Normal file
366
public/scripts/extensions/quick-reply/src/ui/SettingsUi.js
Normal file
@@ -0,0 +1,366 @@
|
||||
import { callPopup } from '../../../../../script.js';
|
||||
import { getSortableDelay } from '../../../../utils.js';
|
||||
import { log, warn } from '../../index.js';
|
||||
import { QuickReply } from '../QuickReply.js';
|
||||
import { QuickReplySet } from '../QuickReplySet.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { QuickReplySettings } from '../QuickReplySettings.js';
|
||||
|
||||
export class SettingsUi {
|
||||
/**@type {QuickReplySettings}*/ settings;
|
||||
|
||||
/**@type {HTMLElement}*/ template;
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
|
||||
/**@type {HTMLInputElement}*/ isEnabled;
|
||||
/**@type {HTMLInputElement}*/ isCombined;
|
||||
|
||||
/**@type {HTMLElement}*/ globalSetList;
|
||||
|
||||
/**@type {HTMLElement}*/ chatSetList;
|
||||
|
||||
/**@type {QuickReplySet}*/ currentQrSet;
|
||||
/**@type {HTMLInputElement}*/ disableSend;
|
||||
/**@type {HTMLInputElement}*/ placeBeforeInput;
|
||||
/**@type {HTMLInputElement}*/ injectInput;
|
||||
/**@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());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
prepareQrEditor() {
|
||||
// qr editor
|
||||
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-delete').addEventListener('click', async()=>this.deleteQrSet());
|
||||
this.dom.querySelector('#qr--set-add').addEventListener('click', async()=>{
|
||||
this.currentQrSet.addQuickReply();
|
||||
});
|
||||
this.qrList = this.dom.querySelector('#qr--set-qrList');
|
||||
this.currentSet = this.dom.querySelector('#qr--set');
|
||||
this.currentSet.addEventListener('change', ()=>this.onQrSetChange());
|
||||
QuickReplySet.list.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();
|
||||
});
|
||||
this.onQrSetChange();
|
||||
}
|
||||
onQrSetChange() {
|
||||
this.currentQrSet = QuickReplySet.get(this.currentSet.value);
|
||||
this.disableSend.checked = this.currentQrSet.disableSend;
|
||||
this.placeBeforeInput.checked = this.currentQrSet.placeBeforeInput;
|
||||
this.injectInput.checked = this.currentQrSet.injectInput;
|
||||
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.prepareQrEditor();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async onIsEnabled() {
|
||||
this.settings.isEnabled = this.isEnabled.checked;
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async onIsCombined() {
|
||||
this.settings.isCombined = this.isCombined.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 callPopup(`Are you sure you want to delete the Quick Reply Set "${this.currentQrSet.name}"?<br>This cannot be undone.`, 'confirm');
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async addQrSet() {
|
||||
const name = await callPopup('Quick Reply Set Name:', 'input');
|
||||
if (name && name.length > 0) {
|
||||
const oldQrs = QuickReplySet.get(name);
|
||||
if (oldQrs) {
|
||||
const replace = await callPopup(`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.`, 'confirm');
|
||||
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();
|
||||
}
|
||||
} else {
|
||||
const qrs = new QuickReplySet();
|
||||
qrs.name = name;
|
||||
qrs.addQuickReply();
|
||||
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(name) == 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = await callPopup(`A Quick Reply Set named "${qrs.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.`, 'confirm');
|
||||
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();
|
||||
}
|
||||
} else {
|
||||
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(qrs.name) == 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();
|
||||
}
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
selectQrSet(qrs) {
|
||||
this.currentSet.value = qrs.name;
|
||||
this.onQrSetChange();
|
||||
}
|
||||
}
|
108
public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js
Normal file
108
public/scripts/extensions/quick-reply/src/ui/ctx/ContextMenu.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { QuickReply } from '../../QuickReply.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
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 = {
|
||||
label: qr.label,
|
||||
message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
|
||||
children: [],
|
||||
};
|
||||
qr.contextList.forEach((cl) => {
|
||||
if (!hierarchy.includes(cl.set)) {
|
||||
const nextHierarchy = [...hierarchy, cl.set];
|
||||
const nextLabelHierarchy = [...labelHierarchy, tree.label];
|
||||
tree.children.push(new MenuHeader(cl.set.name));
|
||||
cl.set.qrList.forEach(subQr => {
|
||||
const subTree = this.build(subQr, cl.isChained ? tree.message : null, nextHierarchy, nextLabelHierarchy);
|
||||
tree.children.push(new MenuItem(
|
||||
subTree.label,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,114 +1,286 @@
|
||||
#quickReplyBar {
|
||||
outline: none;
|
||||
/*
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--SmartThemeBorderColor);
|
||||
*/
|
||||
margin: 0;
|
||||
transition: 0.3s;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
display: none;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
order: 1;
|
||||
position: relative;
|
||||
#qr--bar {
|
||||
outline: none;
|
||||
margin: 0;
|
||||
transition: 0.3s;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
order: 1;
|
||||
padding-right: 2.5em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#quickReplies {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
#qr--bar > #qr--popoutTrigger {
|
||||
position: absolute;
|
||||
right: 0.25em;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#quickReplyPopoutButton {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 0px;
|
||||
#qr--popout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
#quickReplies div {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
background-color: var(--black50a);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 10px;
|
||||
padding: 3px 5px;
|
||||
margin: 3px 0;
|
||||
/* width: min-content; */
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
#qr--popout > .qr--header {
|
||||
flex: 0 0 auto;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#quickReplies div:hover {
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
cursor: pointer;
|
||||
#qr--popout > .qr--header > .qr--controls > .qr--close {
|
||||
height: 15px;
|
||||
aspect-ratio: 1 / 1;
|
||||
font-size: 20px;
|
||||
opacity: 0.5;
|
||||
transition: all 250ms;
|
||||
}
|
||||
#qr--popout > .qr--body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
#qr--bar > .qr--buttons,
|
||||
#qr--popout > .qr--body > .qr--buttons {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
#qr--bar > .qr--buttons > .qr--buttons,
|
||||
#qr--popout > .qr--body > .qr--buttons > .qr--buttons {
|
||||
display: contents;
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button,
|
||||
#qr--popout > .qr--body > .qr--buttons .qr--button {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 10px;
|
||||
padding: 3px 5px;
|
||||
margin: 3px 0;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button:hover,
|
||||
#qr--popout > .qr--body > .qr--buttons .qr--button:hover {
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button > .qr--button-expander,
|
||||
#qr--popout > .qr--body > .qr--buttons .qr--button > .qr--button-expander {
|
||||
display: none;
|
||||
}
|
||||
#qr--bar > .qr--buttons .qr--button.qr--hasCtx > .qr--button-expander,
|
||||
#qr--popout > .qr--body > .qr--buttons .qr--button.qr--hasCtx > .qr--button-expander {
|
||||
display: block;
|
||||
}
|
||||
.qr--button-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
}
|
||||
.qr--button-expander:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.ctx-blocker {
|
||||
/* backdrop-filter: blur(1px); */
|
||||
/* background-color: rgba(0 0 0 / 10%); */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
/* backdrop-filter: blur(1px); */
|
||||
/* background-color: rgba(0 0 0 / 10%); */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.list-group .list-group-item.ctx-header {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctx-item+.ctx-header {
|
||||
border-top: 1px solid;
|
||||
.ctx-item + .ctx-header {
|
||||
border-top: 1px solid;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
position: relative;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ctx-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.ctx-expander:hover {
|
||||
font-weight: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ctx-sub-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
.ctx-blocker {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.list-group .list-group-item.ctx-item {
|
||||
padding: 1em;
|
||||
}
|
||||
.ctx-blocker {
|
||||
position: absolute;
|
||||
}
|
||||
.list-group .list-group-item.ctx-item {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
#qr--settings .qr--head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1em;
|
||||
}
|
||||
#qr--settings .qr--head > .qr--title {
|
||||
font-weight: bold;
|
||||
}
|
||||
#qr--settings .qr--head > .qr--actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
#qr--settings .qr--setList > .qr--item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
#qr--settings .qr--setList > .qr--item > .drag-handle {
|
||||
padding: 0.75em;
|
||||
}
|
||||
#qr--settings .qr--setList > .qr--item > .qr--visible {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
#qr--settings #qr--set-settings #qr--injectInputContainer {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents {
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(1) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(2) {
|
||||
flex: 1 1 25%;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(3) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(4) {
|
||||
flex: 1 1 75%;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > :nth-child(5) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item > .drag-handle {
|
||||
padding: 0.75em;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemLabel,
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--action {
|
||||
margin: 0;
|
||||
}
|
||||
#qr--settings #qr--set-qrList .qr--set-qrListContents > .qr--set-item .qr--set-itemMessage {
|
||||
font-size: smaller;
|
||||
}
|
||||
#qr--settings .qr--set-qrListActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
justify-content: center;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
#qr--qrOptions > #qr--ctxEditor .qr--ctxItem {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
}
|
||||
@media screen and (max-width: 750px) {
|
||||
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor {
|
||||
flex-direction: column;
|
||||
}
|
||||
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels {
|
||||
flex-direction: column;
|
||||
}
|
||||
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-message {
|
||||
min-height: 90svh;
|
||||
}
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) {
|
||||
aspect-ratio: unset;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1em;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label {
|
||||
flex: 1 1 1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelText {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelHint {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-message {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor #qr--modal-execute {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor #qr--modal-execute.qr--busy {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
#shadow_popup.qr--hide {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
317
public/scripts/extensions/quick-reply/style.less
Normal file
317
public/scripts/extensions/quick-reply/style.less
Normal file
@@ -0,0 +1,317 @@
|
||||
#qr--bar {
|
||||
outline: none;
|
||||
margin: 0;
|
||||
transition: 0.3s;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
order: 1;
|
||||
padding-right: 2.5em;
|
||||
position: relative;
|
||||
> #qr--popoutTrigger {
|
||||
position: absolute;
|
||||
right: 0.25em;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
#qr--popout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
z-index: 31;
|
||||
> .qr--header {
|
||||
flex: 0 0 auto;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
> .qr--controls {
|
||||
> .qr--close {
|
||||
height: 15px;
|
||||
aspect-ratio: 1 / 1;
|
||||
font-size: 20px;
|
||||
opacity: 0.5;
|
||||
transition: all 250ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
> .qr--body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
#qr--bar, #qr--popout > .qr--body {
|
||||
> .qr--buttons {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
|
||||
> .qr--buttons {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.qr--button {
|
||||
color: var(--SmartThemeBodyColor);
|
||||
// background-color: var(--black50a);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 10px;
|
||||
padding: 3px 5px;
|
||||
margin: 3px 0;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
> .qr--button-expander {
|
||||
display: none;
|
||||
}
|
||||
&.qr--hasCtx {
|
||||
> .qr--button-expander {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr--button-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
&:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.ctx-blocker {
|
||||
/* backdrop-filter: blur(1px); */
|
||||
/* background-color: rgba(0 0 0 / 10%); */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.list-group .list-group-item.ctx-header {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctx-item+.ctx-header {
|
||||
border-top: 1px solid;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ctx-expander {
|
||||
border-left: 1px solid;
|
||||
margin-left: 1em;
|
||||
text-align: center;
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.ctx-expander:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ctx-sub-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
.ctx-blocker {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.list-group .list-group-item.ctx-item {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#qr--settings {
|
||||
.qr--head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1em;
|
||||
> .qr--title {
|
||||
font-weight: bold;
|
||||
}
|
||||
> .qr--actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
}
|
||||
.qr--setList {
|
||||
> .qr--item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
padding: 0 0.5em;
|
||||
> .drag-handle {
|
||||
padding: 0.75em;
|
||||
}
|
||||
> .qr--visible {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
#qr--set-settings {
|
||||
#qr--injectInputContainer {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
#qr--set-qrList {
|
||||
.qr--set-qrListContents > {
|
||||
padding: 0 0.5em;
|
||||
> .qr--set-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
padding: 0.25em 0;
|
||||
> :nth-child(1) { flex: 0 0 auto; }
|
||||
> :nth-child(2) { flex: 1 1 25%; }
|
||||
> :nth-child(3) { flex: 0 0 auto; }
|
||||
> :nth-child(4) { flex: 1 1 75%; }
|
||||
> :nth-child(5) { flex: 0 0 auto; }
|
||||
> .drag-handle {
|
||||
padding: 0.75em;
|
||||
}
|
||||
.qr--set-itemLabel, .qr--action {
|
||||
margin: 0;
|
||||
}
|
||||
.qr--set-itemMessage {
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.qr--set-qrListActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
justify-content: center;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#qr--qrOptions {
|
||||
> #qr--ctxEditor {
|
||||
.qr--ctxItem {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
body #dialogue_popup:has(#qr--modalEditor) #dialogue_popup_text > #qr--modalEditor {
|
||||
flex-direction: column;
|
||||
> #qr--main > .qr--labels {
|
||||
flex-direction: column;
|
||||
}
|
||||
> #qr--main > .qr--modal-messageContainer > #qr--modal-message {
|
||||
min-height: 90svh;
|
||||
}
|
||||
}
|
||||
}
|
||||
#dialogue_popup:has(#qr--modalEditor) {
|
||||
aspect-ratio: unset;
|
||||
|
||||
#dialogue_popup_text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> #qr--modalEditor {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1em;
|
||||
|
||||
> #qr--main {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
> .qr--labels {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
> label {
|
||||
flex: 1 1 1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
> .qr--labelText {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
> .qr--labelHint {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
> input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
> .qr--modal-messageContainer {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
> #qr--modal-message {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#qr--modal-execute {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
&.qr--busy {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#shadow_popup.qr--hide {
|
||||
opacity: 0 !important;
|
||||
}
|
@@ -1,9 +1,14 @@
|
||||
<div id="regex_editor_template">
|
||||
<div class="regex_editor">
|
||||
<h3><strong data-i18n="Regex Editor">Regex Editor</strong>
|
||||
<h3 class="flex-container justifyCenter alignItemsBaseline">
|
||||
<strong data-i18n="Regex Editor">Regex Editor</strong>
|
||||
<a href="https://regexr.com/" class="notes-link" target="_blank">
|
||||
<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">
|
||||
@@ -11,6 +16,22 @@
|
||||
</small>
|
||||
<hr />
|
||||
|
||||
<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" 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" placeholder="Empty" readonly></textarea>
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<div class="flex1">
|
||||
<label for="regex_script_name" class="title_restorable">
|
||||
@@ -35,7 +56,7 @@
|
||||
<div>
|
||||
<textarea
|
||||
class="regex_replace_string text_pole wide100p textarea_compact"
|
||||
placeholder="Use {{match}} to include the matched text from the Find Regex"
|
||||
placeholder="Use {{match}} to include the matched text from the Find Regex or $1, $2, etc. for capture groups."
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
@@ -94,16 +115,16 @@
|
||||
<input type="checkbox" name="run_on_edit" />
|
||||
<span data-i18n="Run On Edit">Run On Edit</span>
|
||||
</label>
|
||||
<label class="checkbox flex-container">
|
||||
<label class="checkbox flex-container" title="Substitute {{macros}} in Find Regex before running it">
|
||||
<input type="checkbox" name="substitute_regex" />
|
||||
<span data-i18n="Substitute Regex">Substitute Regex</span>
|
||||
<span data-i18n="Substitute Regex">Substitute Regex (?)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-container flexFlowColumn alignitemsstart">
|
||||
<small>Replacement Strategy</small>
|
||||
<select name="replace_strategy_select" class="margin0">
|
||||
<option value="0">Replace</option>
|
||||
<option value="1">Overlay</option>
|
||||
<option value="1">Overlay (currently broken)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -6,20 +6,33 @@ export {
|
||||
runRegexScript,
|
||||
};
|
||||
|
||||
/**
|
||||
* @enum {number} Where the regex script should be applied
|
||||
*/
|
||||
const regex_placement = {
|
||||
// MD Display is deprecated. Do not use.
|
||||
/**
|
||||
* @deprecated MD Display is deprecated. Do not use.
|
||||
*/
|
||||
MD_DISPLAY: 0,
|
||||
USER_INPUT: 1,
|
||||
AI_OUTPUT: 2,
|
||||
SLASH_COMMAND: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* @enum {number} How the regex script should replace the matched string
|
||||
*/
|
||||
const regex_replace_strategy = {
|
||||
REPLACE: 0,
|
||||
OVERLAY: 1,
|
||||
};
|
||||
|
||||
// Originally from: https://github.com/IonicaBizau/regex-parser.js/blob/master/lib/index.js
|
||||
/**
|
||||
* Instantiates a regular expression from a string.
|
||||
* @param {string} input The input string.
|
||||
* @returns {RegExp} The regular expression instance.
|
||||
* @copyright Originally from: https://github.com/IonicaBizau/regex-parser.js/blob/master/lib/index.js
|
||||
*/
|
||||
function regexFromString(input) {
|
||||
try {
|
||||
// Parse input
|
||||
@@ -37,8 +50,21 @@ function regexFromString(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parent function to fetch a regexed version of a raw string
|
||||
/**
|
||||
* 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 }} RegexParams The parameters to use for the regex script
|
||||
*/
|
||||
function getRegexedString(rawString, placement, { characterOverride, isMarkdown, isPrompt } = {}) {
|
||||
// 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;
|
||||
@@ -62,14 +88,20 @@ function getRegexedString(rawString, placement, { characterOverride, isMarkdown,
|
||||
return finalString;
|
||||
}
|
||||
|
||||
// Runs the provided regex script on the given string
|
||||
/**
|
||||
* Runs the provided regex script on the given string
|
||||
* @param {object} 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
|
||||
*/
|
||||
function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
|
||||
let newString = rawString;
|
||||
if (!regexScript || !!(regexScript.disabled) || !regexScript?.findRegex || !rawString) {
|
||||
return newString;
|
||||
}
|
||||
|
||||
let match;
|
||||
const findRegex = regexFromString(regexScript.substituteRegex ? substituteParams(regexScript.findRegex) : regexScript.findRegex);
|
||||
|
||||
// The user skill issued. Return with nothing.
|
||||
@@ -77,46 +109,41 @@ function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
|
||||
return newString;
|
||||
}
|
||||
|
||||
while ((match = findRegex.exec(rawString)) !== null) {
|
||||
const fencedMatch = match[0];
|
||||
const capturedMatch = match[1];
|
||||
// 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) => {
|
||||
// Get a full match or a capture group
|
||||
const match = args[Number(num)];
|
||||
|
||||
let trimCapturedMatch;
|
||||
let trimFencedMatch;
|
||||
if (capturedMatch) {
|
||||
const tempTrimCapture = filterString(capturedMatch, regexScript.trimStrings, { characterOverride });
|
||||
trimFencedMatch = fencedMatch.replaceAll(capturedMatch, tempTrimCapture);
|
||||
trimCapturedMatch = tempTrimCapture;
|
||||
} else {
|
||||
trimFencedMatch = filterString(fencedMatch, regexScript.trimStrings, { characterOverride });
|
||||
}
|
||||
// No match found - return the empty string
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// TODO: Use substrings for replacement. But not necessary at this time.
|
||||
// A substring is from match.index to match.index + match[0].length or fencedMatch.length
|
||||
const subReplaceString = substituteRegexParams(
|
||||
regexScript.replaceString,
|
||||
trimCapturedMatch ?? trimFencedMatch,
|
||||
{
|
||||
characterOverride,
|
||||
replaceStrategy: regexScript.replaceStrategy ?? regex_replace_strategy.REPLACE,
|
||||
},
|
||||
);
|
||||
if (!newString) {
|
||||
newString = rawString.replace(fencedMatch, subReplaceString);
|
||||
} else {
|
||||
newString = newString.replace(fencedMatch, subReplaceString);
|
||||
}
|
||||
// Remove trim strings from the match
|
||||
const filteredMatch = filterString(match, regexScript.trimStrings, { characterOverride });
|
||||
|
||||
// If the regex isn't global, break out of the loop
|
||||
if (!findRegex.flags.includes('g')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// TODO: Handle overlay here
|
||||
|
||||
return filteredMatch;
|
||||
});
|
||||
|
||||
// Substitute at the end
|
||||
return substituteParams(replaceWithGroups);
|
||||
});
|
||||
|
||||
return newString;
|
||||
}
|
||||
|
||||
// Filters anything to trim from the regex match
|
||||
/**
|
||||
* 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) => {
|
||||
@@ -127,7 +154,14 @@ function filterString(rawString, trimStrings, { characterOverride } = {}) {
|
||||
return finalString;
|
||||
}
|
||||
|
||||
// Substitutes regex-specific and normal parameters
|
||||
/**
|
||||
* Substitutes regex-specific and normal parameters
|
||||
* @param {string} rawString
|
||||
* @param {string} regexMatch
|
||||
* @param {RegexSubstituteParams} params The parameters to use for the regex substitution
|
||||
* @returns {string} The substituted string
|
||||
* @typedef {{characterOverride?: string, replaceStrategy?: number}} RegexSubstituteParams The parameters to use for the regex substitution
|
||||
*/
|
||||
function substituteRegexParams(rawString, regexMatch, { characterOverride, replaceStrategy } = {}) {
|
||||
let finalString = rawString;
|
||||
finalString = substituteParams(finalString, undefined, characterOverride);
|
||||
@@ -182,8 +216,13 @@ function substituteRegexParams(rawString, regexMatch, { characterOverride, repla
|
||||
return finalString;
|
||||
}
|
||||
|
||||
// Splices common sentence symbols and whitespace from the beginning and end of a string
|
||||
// Using a for loop due to sequential ordering
|
||||
/**
|
||||
* Splices common sentence symbols and whitespace from the beginning and end of a string.
|
||||
* Using a for loop due to sequential ordering.
|
||||
* @param {string} rawString The raw string to splice
|
||||
* @param {boolean} isSuffix String is a suffix
|
||||
* @returns {string} The spliced string
|
||||
*/
|
||||
function spliceSymbols(rawString, isSuffix) {
|
||||
let offset = 0;
|
||||
|
||||
|
@@ -165,6 +165,31 @@ async function onRegexEditorOpenClick(existingId) {
|
||||
.prop('checked', true);
|
||||
}
|
||||
|
||||
editorHtml.find('#regex_test_mode_toggle').on('click', function () {
|
||||
editorHtml.find('#regex_test_mode').toggleClass('displayNone');
|
||||
updateTestResult();
|
||||
});
|
||||
|
||||
function updateTestResult() {
|
||||
if (!editorHtml.find('#regex_test_mode').is(':visible')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const testScript = {
|
||||
scriptName: editorHtml.find('.regex_script_name').val(),
|
||||
findRegex: editorHtml.find('.find_regex').val(),
|
||||
replaceString: editorHtml.find('.regex_replace_string').val(),
|
||||
trimStrings: String(editorHtml.find('.regex_trim_strings').val()).split('\n').filter((e) => e.length !== 0) || [],
|
||||
substituteRegex: editorHtml.find('input[name="substitute_regex"]').prop('checked'),
|
||||
replaceStrategy: Number(editorHtml.find('select[name="replace_strategy_select"]').find(':selected').val()) ?? 0,
|
||||
};
|
||||
const rawTestString = String(editorHtml.find('#regex_test_input').val());
|
||||
const result = runRegexScript(testScript, rawTestString);
|
||||
editorHtml.find('#regex_test_output').text(result);
|
||||
}
|
||||
|
||||
editorHtml.find('input, textarea, select').on('input', updateTestResult);
|
||||
|
||||
const popupResult = await callPopup(editorHtml, 'confirm', undefined, { okButton: 'Save' });
|
||||
if (popupResult) {
|
||||
const newRegexScript = {
|
||||
|
@@ -21,13 +21,15 @@ export async function getMultimodalCaption(base64Img, prompt) {
|
||||
}
|
||||
|
||||
// OpenRouter has a payload limit of ~2MB. Google is 4MB, but we love democracy.
|
||||
// Ooba requires all images to be JPEGs.
|
||||
const isGoogle = extension_settings.caption.multimodal_api === 'google';
|
||||
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 base64Bytes = base64Img.length * 0.75;
|
||||
const compressionLimit = 2 * 1024 * 1024;
|
||||
if (['google', 'openrouter'].includes(extension_settings.caption.multimodal_api) && base64Bytes > compressionLimit) {
|
||||
if ((['google', 'openrouter'].includes(extension_settings.caption.multimodal_api) && base64Bytes > compressionLimit) || isOoba) {
|
||||
const maxSide = 1024;
|
||||
base64Img = await createThumbnail(base64Img, maxSide, maxSide, 'image/jpeg');
|
||||
|
||||
@@ -69,6 +71,10 @@ export async function getMultimodalCaption(base64Img, prompt) {
|
||||
requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.LLAMACPP];
|
||||
}
|
||||
|
||||
if (isOoba) {
|
||||
requestBody.server_url = textgenerationwebui_settings.server_urls[textgen_types.OOBA];
|
||||
}
|
||||
|
||||
if (isCustom) {
|
||||
requestBody.server_url = oai_settings.custom_url;
|
||||
requestBody.model = oai_settings.custom_model || 'gpt-4-vision-preview';
|
||||
@@ -129,6 +135,10 @@ function throwIfInvalidModel() {
|
||||
throw new Error('LlamaCPP server URL is not set.');
|
||||
}
|
||||
|
||||
if (extension_settings.caption.multimodal_api === 'ooba' && !textgenerationwebui_settings.server_urls[textgen_types.OOBA]) {
|
||||
throw new Error('Text Generation WebUI server URL is not set.');
|
||||
}
|
||||
|
||||
if (extension_settings.caption.multimodal_api === 'custom' && !oai_settings.custom_url) {
|
||||
throw new Error('Custom API URL is not set.');
|
||||
}
|
||||
|
@@ -2286,7 +2286,7 @@ async function generateComfyImage(prompt, negativePrompt) {
|
||||
toastr.error(`Failed to load workflow.\n\n${text}`);
|
||||
}
|
||||
let workflow = (await workflowResponse.json()).replace('"%prompt%"', JSON.stringify(prompt));
|
||||
workflow = (await workflowResponse.json()).replace('"%negative_prompt%"', JSON.stringify(negativePrompt));
|
||||
workflow = workflow.replace('"%negative_prompt%"', JSON.stringify(negativePrompt));
|
||||
workflow = workflow.replace('"%seed%"', JSON.stringify(Math.round(Math.random() * Number.MAX_SAFE_INTEGER)));
|
||||
placeholders.forEach(ph => {
|
||||
workflow = workflow.replace(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
|
||||
@@ -2642,7 +2642,7 @@ $('#sd_dropdown [id]').on('click', function () {
|
||||
|
||||
jQuery(async () => {
|
||||
registerSlashCommand('imagine', generatePicture, ['sd', 'img', 'image'], helpString, true, true);
|
||||
registerSlashCommand('imagine-comfy-workflow', changeComfyWorkflow, ['icw'], '(workflowName) - change the workflow to be used for image generation with ComfyUI, e.g. <tt>/imagine-comfy-workflow MyWorkflow</tt>')
|
||||
registerSlashCommand('imagine-comfy-workflow', changeComfyWorkflow, ['icw'], '(workflowName) - change the workflow to be used for image generation with ComfyUI, e.g. <tt>/imagine-comfy-workflow MyWorkflow</tt>');
|
||||
|
||||
$('#extensions_settings').append(renderExtensionTemplate('stable-diffusion', 'settings', defaultSettings));
|
||||
$('#sd_source').on('change', onSourceChange);
|
||||
|
@@ -310,12 +310,12 @@ class CoquiTtsProvider {
|
||||
modelDict = coquiApiModelsFull;
|
||||
|
||||
if (model_setting_language == null & 'languages' in modelDict[model_language][model_dataset][model_label]) {
|
||||
toastr.error('Model language not selected, please select one.', DEBUG_PREFIX+' voice mapping model language', { timeOut: 10000, extendedTimeOut: 20000, preventDuplicates: true });
|
||||
toastr.error('Model language not selected, please select one.', DEBUG_PREFIX + ' voice mapping model language', { timeOut: 10000, extendedTimeOut: 20000, preventDuplicates: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (model_setting_speaker == null & 'speakers' in modelDict[model_language][model_dataset][model_label]) {
|
||||
toastr.error('Model speaker not selected, please select one.', DEBUG_PREFIX+' voice mapping model speaker', { timeOut: 10000, extendedTimeOut: 20000, preventDuplicates: true });
|
||||
toastr.error('Model speaker not selected, please select one.', DEBUG_PREFIX + ' voice mapping model speaker', { timeOut: 10000, extendedTimeOut: 20000, preventDuplicates: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { callPopup, cancelTtsPlay, eventSource, event_types, name2, saveSettingsDebounced } from '../../../script.js';
|
||||
import { ModuleWorkerWrapper, doExtrasFetch, extension_settings, getApiUrl, getContext, modules } from '../../extensions.js';
|
||||
import { delay, escapeRegex, getStringHash } from '../../utils.js';
|
||||
import { delay, escapeRegex, getBase64Async, getStringHash, onlyUnique } from '../../utils.js';
|
||||
import { EdgeTtsProvider } from './edge.js';
|
||||
import { ElevenLabsTtsProvider } from './elevenlabs.js';
|
||||
import { SileroTtsProvider } from './silerotts.js';
|
||||
@@ -318,12 +318,14 @@ async function playAudioData(audioBlob) {
|
||||
if (currentAudioJob == null) {
|
||||
console.log('Cancelled TTS playback because currentAudioJob was null');
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
const srcUrl = e.target.result;
|
||||
if (audioBlob instanceof Blob) {
|
||||
const srcUrl = await getBase64Async(audioBlob);
|
||||
audioElement.src = srcUrl;
|
||||
};
|
||||
reader.readAsDataURL(audioBlob);
|
||||
} else if (typeof audioBlob === 'string') {
|
||||
audioElement.src = audioBlob;
|
||||
} else {
|
||||
throw `TTS received invalid audio data type ${typeof audioBlob}`;
|
||||
}
|
||||
audioElement.addEventListener('ended', completeCurrentAudioJob);
|
||||
audioElement.addEventListener('canplay', () => {
|
||||
console.debug('Starting TTS playback');
|
||||
@@ -419,11 +421,15 @@ function completeCurrentAudioJob() {
|
||||
* @param {Response} response
|
||||
*/
|
||||
async function addAudioJob(response) {
|
||||
const audioData = await response.blob();
|
||||
if (!audioData.type.startsWith('audio/')) {
|
||||
throw `TTS received HTTP response with invalid data format. Expecting audio/*, got ${audioData.type}`;
|
||||
if (typeof response === 'string') {
|
||||
audioJobQueue.push(response);
|
||||
} else {
|
||||
const audioData = await response.blob();
|
||||
if (!audioData.type.startsWith('audio/')) {
|
||||
throw `TTS received HTTP response with invalid data format. Expecting audio/*, got ${audioData.type}`;
|
||||
}
|
||||
audioJobQueue.push(audioData);
|
||||
}
|
||||
audioJobQueue.push(audioData);
|
||||
console.debug('Pushed audio job to queue.');
|
||||
}
|
||||
|
||||
@@ -434,7 +440,7 @@ async function processAudioJobQueue() {
|
||||
}
|
||||
try {
|
||||
audioQueueProcessorReady = false;
|
||||
currentAudioJob = audioJobQueue.pop();
|
||||
currentAudioJob = audioJobQueue.shift();
|
||||
playAudioData(currentAudioJob);
|
||||
talkingAnimation(true);
|
||||
} catch (error) {
|
||||
@@ -465,13 +471,25 @@ function saveLastValues() {
|
||||
}
|
||||
|
||||
async function tts(text, voiceId, char) {
|
||||
async function processResponse(response) {
|
||||
// RVC injection
|
||||
if (extension_settings.rvc.enabled && typeof window['rvcVoiceConversion'] === 'function')
|
||||
response = await window['rvcVoiceConversion'](response, char, text);
|
||||
|
||||
await addAudioJob(response);
|
||||
}
|
||||
|
||||
let response = await ttsProvider.generateTts(text, voiceId);
|
||||
|
||||
// RVC injection
|
||||
if (extension_settings.rvc.enabled && typeof window['rvcVoiceConversion'] === 'function')
|
||||
response = await window['rvcVoiceConversion'](response, char, text);
|
||||
// If async generator, process every chunk as it comes in
|
||||
if (typeof response[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of response) {
|
||||
await processResponse(chunk);
|
||||
}
|
||||
} else {
|
||||
await processResponse(response);
|
||||
}
|
||||
|
||||
addAudioJob(response);
|
||||
completeTtsJob();
|
||||
}
|
||||
|
||||
@@ -746,7 +764,7 @@ function getCharacters(unrestricted) {
|
||||
if (unrestricted) {
|
||||
const names = context.characters.map(char => char.name);
|
||||
names.unshift(DEFAULT_VOICE_MARKER);
|
||||
return names;
|
||||
return names.filter(onlyUnique);
|
||||
}
|
||||
|
||||
let characters = [];
|
||||
@@ -761,14 +779,13 @@ function getCharacters(unrestricted) {
|
||||
characters.push(context.name1);
|
||||
const group = context.groups.find(group => context.groupId == group.id);
|
||||
for (let member of group.members) {
|
||||
// Remove suffix
|
||||
if (member.endsWith('.png')) {
|
||||
member = member.slice(0, -4);
|
||||
const character = context.characters.find(char => char.avatar == member);
|
||||
if (character) {
|
||||
characters.push(character.name);
|
||||
}
|
||||
characters.push(member);
|
||||
}
|
||||
}
|
||||
return characters;
|
||||
return characters.filter(onlyUnique);
|
||||
}
|
||||
|
||||
function sanitizeId(input) {
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { getRequestHeaders, callPopup } from '../../../script.js';
|
||||
import { splitRecursive } from '../../utils.js';
|
||||
import { getPreviewString, saveTtsProviderSettings } from './index.js';
|
||||
import { initVoiceMap } from './index.js';
|
||||
|
||||
@@ -52,7 +53,7 @@ class NovelTtsProvider {
|
||||
|
||||
|
||||
// Add a new Novel custom voice to provider
|
||||
async addCustomVoice(){
|
||||
async addCustomVoice() {
|
||||
const voiceName = await callPopup('<h3>Custom Voice name:</h3>', 'input');
|
||||
this.settings.customVoices.push(voiceName);
|
||||
this.populateCustomVoices();
|
||||
@@ -74,7 +75,7 @@ class NovelTtsProvider {
|
||||
}
|
||||
|
||||
// Create the UI dropdown list of voices in provider
|
||||
populateCustomVoices(){
|
||||
populateCustomVoices() {
|
||||
let voiceSelect = $('#tts-novel-custom-voices-select');
|
||||
voiceSelect.empty();
|
||||
this.settings.customVoices.forEach(voice => {
|
||||
@@ -88,7 +89,7 @@ class NovelTtsProvider {
|
||||
console.info('Using default TTS Provider settings');
|
||||
}
|
||||
$('#tts-novel-custom-voices-add').on('click', () => (this.addCustomVoice()));
|
||||
$('#tts-novel-custom-voices-delete').on('click',() => (this.deleteCustomVoice()));
|
||||
$('#tts-novel-custom-voices-delete').on('click', () => (this.deleteCustomVoice()));
|
||||
|
||||
// Only accept keys defined in defaultSettings
|
||||
this.settings = this.defaultSettings;
|
||||
@@ -108,7 +109,7 @@ class NovelTtsProvider {
|
||||
|
||||
// Perform a simple readiness check by trying to fetch voiceIds
|
||||
// Doesnt really do much for Novel, not seeing a good way to test this at the moment.
|
||||
async checkReady(){
|
||||
async checkReady() {
|
||||
await this.fetchTtsVoiceObjects();
|
||||
}
|
||||
|
||||
@@ -179,22 +180,26 @@ class NovelTtsProvider {
|
||||
this.audioElement.play();
|
||||
}
|
||||
|
||||
async fetchTtsGeneration(inputText, voiceId) {
|
||||
async* fetchTtsGeneration(inputText, voiceId) {
|
||||
const MAX_LENGTH = 1000;
|
||||
console.info(`Generating new TTS for voice_id ${voiceId}`);
|
||||
const response = await fetch('/api/novelai/generate-voice',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({
|
||||
'text': inputText,
|
||||
'voice': voiceId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
toastr.error(response.statusText, 'TTS Generation Failed');
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
const chunks = splitRecursive(inputText, MAX_LENGTH);
|
||||
for (const chunk of chunks) {
|
||||
const response = await fetch('/api/novelai/generate-voice',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({
|
||||
'text': chunk,
|
||||
'voice': voiceId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
toastr.error(response.statusText, 'TTS Generation Failed');
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
yield response;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
@@ -51,7 +51,16 @@ class XTTSTtsProvider {
|
||||
defaultSettings = {
|
||||
provider_endpoint: 'http://localhost:8020',
|
||||
language: 'en',
|
||||
temperature: 0.75,
|
||||
length_penalty: 1.0,
|
||||
repetition_penalty: 5.0,
|
||||
top_k: 50,
|
||||
top_p: 0.85,
|
||||
speed: 1,
|
||||
enable_text_splitting: true,
|
||||
stream_chunk_size: 100,
|
||||
voiceMap: {},
|
||||
streaming: false,
|
||||
};
|
||||
|
||||
get settingsHtml() {
|
||||
@@ -59,9 +68,7 @@ class XTTSTtsProvider {
|
||||
<label for="xtts_api_language">Language</label>
|
||||
<select id="xtts_api_language">`;
|
||||
|
||||
|
||||
for (let language in this.languageLabels) {
|
||||
|
||||
if (this.languageLabels[language] == this.settings?.language) {
|
||||
html += `<option value="${this.languageLabels[language]}" selected="selected">${language}</option>`;
|
||||
continue;
|
||||
@@ -70,27 +77,73 @@ class XTTSTtsProvider {
|
||||
html += `<option value="${this.languageLabels[language]}">${language}</option>`;
|
||||
}
|
||||
|
||||
|
||||
html += `
|
||||
</select>
|
||||
<label">XTTS Settings:</label><br/>
|
||||
<label for="xtts_tts_endpoint">Provider Endpoint:</label>
|
||||
<input id="xtts_tts_endpoint" type="text" class="text_pole" maxlength="250" value="${this.defaultSettings.provider_endpoint}"/>
|
||||
|
||||
`;
|
||||
|
||||
html += `
|
||||
|
||||
<span>
|
||||
<span>Use <a target="_blank" href="https://github.com/daswer123/xtts-api-server">XTTSv2 TTS Server</a>.</span>
|
||||
<label for="xtts_tts_streaming" class="checkbox_label">
|
||||
<input id="xtts_tts_streaming" type="checkbox" />
|
||||
<span>Streaming <small>(RVC not supported)</small></span>
|
||||
</label>
|
||||
<label for="xtts_speed">Speed: <span id="xtts_tts_speed_output">${this.defaultSettings.speed}</span></label>
|
||||
<input id="xtts_speed" type="range" value="${this.defaultSettings.speed}" min="0.5" max="2" step="0.01" />
|
||||
|
||||
<label for="xtts_temperature">Temperature: <span id="xtts_tts_temperature_output">${this.defaultSettings.temperature}</span></label>
|
||||
<input id="xtts_temperature" type="range" value="${this.defaultSettings.temperature}" min="0.01" max="1" step="0.01" />
|
||||
|
||||
<label for="xtts_length_penalty">Length Penalty: <span id="xtts_length_penalty_output">${this.defaultSettings.length_penalty}</span></label>
|
||||
<input id="xtts_length_penalty" type="range" value="${this.defaultSettings.length_penalty}" min="0.5" max="2" step="0.1" />
|
||||
|
||||
<label for="xtts_repetition_penalty">Repetition Penalty: <span id="xtts_repetition_penalty_output">${this.defaultSettings.repetition_penalty}</span></label>
|
||||
<input id="xtts_repetition_penalty" type="range" value="${this.defaultSettings.repetition_penalty}" min="1" max="10" step="0.1" />
|
||||
|
||||
<label for="xtts_top_k">Top K: <span id="xtts_top_k_output">${this.defaultSettings.top_k}</span></label>
|
||||
<input id="xtts_top_k" type="range" value="${this.defaultSettings.top_k}" min="0" max="100" step="1" />
|
||||
|
||||
<label for="xtts_top_p">Top P: <span id="xtts_top_p_output">${this.defaultSettings.top_p}</span></label>
|
||||
<input id="xtts_top_p" type="range" value="${this.defaultSettings.top_p}" min="0" max="1" step="0.01" />
|
||||
|
||||
<label for="xtts_stream_chunk_size">Stream Chunk Size: <span id="xtts_stream_chunk_size_output">${this.defaultSettings.stream_chunk_size}</span></label>
|
||||
<input id="xtts_stream_chunk_size" type="range" value="${this.defaultSettings.stream_chunk_size}" min="100" max="400" step="1" />
|
||||
|
||||
<label for="xtts_enable_text_splitting" class="checkbox_label">
|
||||
<input id="xtts_enable_text_splitting" type="checkbox" ${this.defaultSettings.enable_text_splitting ? 'checked' : ''} />
|
||||
Enable Text Splitting
|
||||
</label>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
onSettingsChange() {
|
||||
// Used when provider settings are updated from UI
|
||||
this.settings.provider_endpoint = $('#xtts_tts_endpoint').val();
|
||||
this.settings.language = $('#xtts_api_language').val();
|
||||
|
||||
// Update the default TTS settings based on input fields
|
||||
this.settings.speed = $('#xtts_speed').val();
|
||||
this.settings.temperature = $('#xtts_temperature').val();
|
||||
this.settings.length_penalty = $('#xtts_length_penalty').val();
|
||||
this.settings.repetition_penalty = $('#xtts_repetition_penalty').val();
|
||||
this.settings.top_k = $('#xtts_top_k').val();
|
||||
this.settings.top_p = $('#xtts_top_p').val();
|
||||
this.settings.stream_chunk_size = $('#xtts_stream_chunk_size').val();
|
||||
this.settings.enable_text_splitting = $('#xtts_enable_text_splitting').is(':checked');
|
||||
this.settings.streaming = $('#xtts_tts_streaming').is(':checked');
|
||||
|
||||
// Update the UI to reflect changes
|
||||
$('#xtts_tts_speed_output').text(this.settings.speed);
|
||||
$('#xtts_tts_temperature_output').text(this.settings.temperature);
|
||||
$('#xtts_length_penalty_output').text(this.settings.length_penalty);
|
||||
$('#xtts_repetition_penalty_output').text(this.settings.repetition_penalty);
|
||||
$('#xtts_top_k_output').text(this.settings.top_k);
|
||||
$('#xtts_top_p_output').text(this.settings.top_p);
|
||||
$('#xtts_stream_chunk_size_output').text(this.settings.stream_chunk_size);
|
||||
|
||||
saveTtsProviderSettings();
|
||||
this.changeTTSSettings();
|
||||
}
|
||||
|
||||
async loadSettings(settings) {
|
||||
@@ -121,10 +174,40 @@ class XTTSTtsProvider {
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Set initial values from the settings
|
||||
$('#xtts_tts_endpoint').val(this.settings.provider_endpoint);
|
||||
$('#xtts_tts_endpoint').on('input', () => { this.onSettingsChange(); });
|
||||
$('#xtts_api_language').val(this.settings.language);
|
||||
$('#xtts_speed').val(this.settings.speed);
|
||||
$('#xtts_temperature').val(this.settings.temperature);
|
||||
$('#xtts_length_penalty').val(this.settings.length_penalty);
|
||||
$('#xtts_repetition_penalty').val(this.settings.repetition_penalty);
|
||||
$('#xtts_top_k').val(this.settings.top_k);
|
||||
$('#xtts_top_p').val(this.settings.top_p);
|
||||
$('#xtts_enable_text_splitting').prop('checked', this.settings.enable_text_splitting);
|
||||
$('#xtts_stream_chunk_size').val(this.settings.stream_chunk_size);
|
||||
$('#xtts_tts_streaming').prop('checked', this.settings.streaming);
|
||||
|
||||
// Update the UI to reflect changes
|
||||
$('#xtts_tts_speed_output').text(this.settings.speed);
|
||||
$('#xtts_tts_temperature_output').text(this.settings.temperature);
|
||||
$('#xtts_length_penalty_output').text(this.settings.length_penalty);
|
||||
$('#xtts_repetition_penalty_output').text(this.settings.repetition_penalty);
|
||||
$('#xtts_top_k_output').text(this.settings.top_k);
|
||||
$('#xtts_top_p_output').text(this.settings.top_p);
|
||||
$('#xtts_stream_chunk_size_output').text(this.settings.stream_chunk_size);
|
||||
|
||||
// Register input/change event listeners to update settings on user interaction
|
||||
$('#xtts_tts_endpoint').on('input', () => { this.onSettingsChange(); });
|
||||
$('#xtts_api_language').on('change', () => { this.onSettingsChange(); });
|
||||
$('#xtts_speed').on('input', () => { this.onSettingsChange(); });
|
||||
$('#xtts_temperature').on('input', () => { this.onSettingsChange(); });
|
||||
$('#xtts_length_penalty').on('input', () => { this.onSettingsChange(); });
|
||||
$('#xtts_repetition_penalty').on('input', () => { this.onSettingsChange(); });
|
||||
$('#xtts_top_k').on('input', () => { this.onSettingsChange(); });
|
||||
$('#xtts_top_p').on('input', () => { this.onSettingsChange(); });
|
||||
$('#xtts_enable_text_splitting').on('change', () => { this.onSettingsChange(); });
|
||||
$('#xtts_stream_chunk_size').on('input', () => { this.onSettingsChange(); });
|
||||
$('#xtts_tts_streaming').on('change', () => { this.onSettingsChange(); });
|
||||
|
||||
await this.checkReady();
|
||||
|
||||
@@ -133,7 +216,7 @@ class XTTSTtsProvider {
|
||||
|
||||
// Perform a simple readiness check by trying to fetch voiceIds
|
||||
async checkReady() {
|
||||
await this.fetchTtsVoiceObjects();
|
||||
await Promise.allSettled([this.fetchTtsVoiceObjects(), this.changeTTSSettings()]);
|
||||
}
|
||||
|
||||
async onRefreshClick() {
|
||||
@@ -174,8 +257,46 @@ class XTTSTtsProvider {
|
||||
return responseJson;
|
||||
}
|
||||
|
||||
// Each time a parameter is changed, we change the configuration
|
||||
async changeTTSSettings() {
|
||||
if (!this.settings.provider_endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await doExtrasFetch(
|
||||
`${this.settings.provider_endpoint}/set_tts_settings`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'temperature': this.settings.temperature,
|
||||
'speed': this.settings.speed,
|
||||
'length_penalty': this.settings.length_penalty,
|
||||
'repetition_penalty': this.settings.repetition_penalty,
|
||||
'top_p': this.settings.top_p,
|
||||
'top_k': this.settings.top_k,
|
||||
'enable_text_splitting': this.settings.enable_text_splitting,
|
||||
'stream_chunk_size': this.settings.stream_chunk_size,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
async fetchTtsGeneration(inputText, voiceId) {
|
||||
console.info(`Generating new TTS for voice_id ${voiceId}`);
|
||||
|
||||
if (this.settings.streaming) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('text', inputText);
|
||||
params.append('speaker_wav', voiceId);
|
||||
params.append('language', this.settings.language);
|
||||
return `${this.settings.provider_endpoint}/tts_stream/?${params.toString()}`;
|
||||
}
|
||||
|
||||
const response = await doExtrasFetch(
|
||||
`${this.settings.provider_endpoint}/tts_to_audio/`,
|
||||
{
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { eventSource, event_types, extension_prompt_types, getCurrentChatId, getRequestHeaders, is_send_press, saveSettingsDebounced, setExtensionPrompt, substituteParams } from '../../../script.js';
|
||||
import { ModuleWorkerWrapper, extension_settings, getContext, renderExtensionTemplate } from '../../extensions.js';
|
||||
import { collapseNewlines, power_user, ui_mode } from '../../power-user.js';
|
||||
import { collapseNewlines } from '../../power-user.js';
|
||||
import { SECRET_KEYS, secret_state } from '../../secrets.js';
|
||||
import { debounce, getStringHash as calculateHash, waitUntilCondition, onlyUnique, splitRecursive } from '../../utils.js';
|
||||
|
||||
@@ -21,6 +21,7 @@ const settings = {
|
||||
protect: 5,
|
||||
insert: 3,
|
||||
query: 2,
|
||||
message_chunk_size: 400,
|
||||
|
||||
// For files
|
||||
enabled_files: false,
|
||||
@@ -87,6 +88,29 @@ async function onVectorizeAllClick() {
|
||||
|
||||
let syncBlocked = false;
|
||||
|
||||
/**
|
||||
* Splits messages into chunks before inserting them into the vector index.
|
||||
* @param {object[]} items Array of vector items
|
||||
* @returns {object[]} Array of vector items (possibly chunked)
|
||||
*/
|
||||
function splitByChunks(items) {
|
||||
if (settings.message_chunk_size <= 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const chunkedItems = [];
|
||||
|
||||
for (const item of items) {
|
||||
const chunks = splitRecursive(item.text, settings.message_chunk_size);
|
||||
for (const chunk of chunks) {
|
||||
const chunkedItem = { ...item, text: chunk };
|
||||
chunkedItems.push(chunkedItem);
|
||||
}
|
||||
}
|
||||
|
||||
return chunkedItems;
|
||||
}
|
||||
|
||||
async function synchronizeChat(batchSize = 5) {
|
||||
if (!settings.enabled_chats) {
|
||||
return -1;
|
||||
@@ -116,8 +140,9 @@ async function synchronizeChat(batchSize = 5) {
|
||||
const deletedHashes = hashesInCollection.filter(x => !hashedMessages.some(y => y.hash === x));
|
||||
|
||||
if (newVectorItems.length > 0) {
|
||||
const chunkedBatch = splitByChunks(newVectorItems.slice(0, batchSize));
|
||||
console.log(`Vectors: Found ${newVectorItems.length} new items. Processing ${batchSize}...`);
|
||||
await insertVectorItems(chatId, newVectorItems.slice(0, batchSize));
|
||||
await insertVectorItems(chatId, chunkedBatch);
|
||||
}
|
||||
|
||||
if (deletedHashes.length > 0) {
|
||||
@@ -492,6 +517,43 @@ function toggleSettings() {
|
||||
$('#vectors_chats_settings').toggle(!!settings.enabled_chats);
|
||||
}
|
||||
|
||||
async function onPurgeClick() {
|
||||
const chatId = getCurrentChatId();
|
||||
if (!chatId) {
|
||||
toastr.info('No chat selected', 'Purge aborted');
|
||||
return;
|
||||
}
|
||||
await purgeVectorIndex(chatId);
|
||||
toastr.success('Vector index purged', 'Purge successful');
|
||||
}
|
||||
|
||||
async function onViewStatsClick() {
|
||||
const chatId = getCurrentChatId();
|
||||
if (!chatId) {
|
||||
toastr.info('No chat selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const hashesInCollection = await getSavedHashes(chatId);
|
||||
const totalHashes = hashesInCollection.length;
|
||||
const uniqueHashes = hashesInCollection.filter(onlyUnique).length;
|
||||
|
||||
toastr.info(`Total hashes: <b>${totalHashes}</b><br>
|
||||
Unique hashes: <b>${uniqueHashes}</b><br><br>
|
||||
I'll mark collected messages with a green circle.`,
|
||||
`Stats for chat ${chatId}`,
|
||||
{ timeOut: 10000, escapeHtml: false });
|
||||
|
||||
const chat = getContext().chat;
|
||||
for (const message of chat) {
|
||||
if (hashesInCollection.includes(getStringHash(message.mes))) {
|
||||
const messageElement = $(`.mes[mesid="${chat.indexOf(message)}"]`);
|
||||
messageElement.addClass('vectorized');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
jQuery(async () => {
|
||||
if (!extension_settings.vectors) {
|
||||
extension_settings.vectors = settings;
|
||||
@@ -554,9 +616,9 @@ jQuery(async () => {
|
||||
Object.assign(extension_settings.vectors, settings);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#vectors_advanced_settings').toggleClass('displayNone', power_user.ui_mode === ui_mode.SIMPLE);
|
||||
|
||||
$('#vectors_vectorize_all').on('click', onVectorizeAllClick);
|
||||
$('#vectors_purge').on('click', onPurgeClick);
|
||||
$('#vectors_view_stats').on('click', onViewStatsClick);
|
||||
|
||||
$('#vectors_size_threshold').val(settings.size_threshold).on('input', () => {
|
||||
settings.size_threshold = Number($('#vectors_size_threshold').val());
|
||||
@@ -582,6 +644,12 @@ jQuery(async () => {
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
$('#vectors_message_chunk_size').val(settings.message_chunk_size).on('input', () => {
|
||||
settings.message_chunk_size = Number($('#vectors_message_chunk_size').val());
|
||||
Object.assign(extension_settings.vectors, settings);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
toggleSettings();
|
||||
eventSource.on(event_types.MESSAGE_DELETED, onChatEvent);
|
||||
eventSource.on(event_types.MESSAGE_EDITED, onChatEvent);
|
||||
|
@@ -5,7 +5,7 @@
|
||||
"optional": [],
|
||||
"generate_interceptor": "vectors_rearrangeChat",
|
||||
"js": "index.js",
|
||||
"css": "",
|
||||
"css": "style.css",
|
||||
"author": "Cohee#1207",
|
||||
"version": "1.0.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
|
@@ -75,7 +75,7 @@
|
||||
</label>
|
||||
|
||||
<div id="vectors_chats_settings">
|
||||
<div id="vectors_advanced_settings" data-newbie-hidden>
|
||||
<div id="vectors_advanced_settings">
|
||||
<label for="vectors_template">
|
||||
Insertion Template
|
||||
</label>
|
||||
@@ -97,17 +97,23 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="flex1" title="Can increase the retrieval quality for the cost of processing. 0 = disabled.">
|
||||
<label for="vectors_message_chunk_size">
|
||||
<small>Chunk size (chars)</small>
|
||||
</label>
|
||||
<input id="vectors_message_chunk_size" type="number" class="text_pole widthUnset" min="0" max="9999" />
|
||||
</div>
|
||||
<div class="flex1" title="Prevents last N messages from being placed out of order.">
|
||||
<label for="vectors_protect">
|
||||
<small>Retain#</small>
|
||||
</label>
|
||||
<input type="number" id="vectors_protect" class="text_pole widthUnset" min="1" max="99" />
|
||||
<input type="number" id="vectors_protect" class="text_pole widthUnset" min="1" max="9999" />
|
||||
</div>
|
||||
<div class="flex1" title="How many past messages to insert as memories.">
|
||||
<label for="vectors_insert">
|
||||
<small>Insert#</small>
|
||||
</label>
|
||||
<input type="number" id="vectors_insert" class="text_pole widthUnset" min="1" max="99" />
|
||||
<input type="number" id="vectors_insert" class="text_pole widthUnset" min="1" max="9999" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,8 +121,16 @@
|
||||
Old messages are vectorized gradually as you chat.
|
||||
To process all previous messages, click the button below.
|
||||
</small>
|
||||
<div id="vectors_vectorize_all" class="menu_button menu_button_icon">
|
||||
Vectorize All
|
||||
<div class="flex-container">
|
||||
<div id="vectors_vectorize_all" class="menu_button menu_button_icon">
|
||||
Vectorize All
|
||||
</div>
|
||||
<div id="vectors_purge" class="menu_button menu_button_icon">
|
||||
Purge Vectors
|
||||
</div>
|
||||
<div id="vectors_view_stats" class="menu_button menu_button_icon">
|
||||
View Stats
|
||||
</div>
|
||||
</div>
|
||||
<div id="vectorize_progress" style="display: none;">
|
||||
<small>
|
||||
|
4
public/scripts/extensions/vectors/style.css
Normal file
4
public/scripts/extensions/vectors/style.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.mes.vectorized .name_text::after {
|
||||
content: '🟢';
|
||||
margin-left: 5px;
|
||||
}
|
Reference in New Issue
Block a user