🎉 初始化项目
This commit is contained in:
888
web-app/public/scripts/BulkEditOverlay.js
Normal file
888
web-app/public/scripts/BulkEditOverlay.js
Normal file
@@ -0,0 +1,888 @@
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
characterGroupOverlay,
|
||||
characters,
|
||||
event_types,
|
||||
eventSource,
|
||||
getCharacters,
|
||||
getRequestHeaders,
|
||||
buildAvatarList,
|
||||
characterToEntity,
|
||||
printCharactersDebounced,
|
||||
deleteCharacter,
|
||||
} from '../script.js';
|
||||
|
||||
import { favsToHotswap } from './RossAscends-mods.js';
|
||||
import { hideLoader, showLoader } from './loader.js';
|
||||
import { convertCharacterToPersona } from './personas.js';
|
||||
import { callGenericPopup, POPUP_TYPE } from './popup.js';
|
||||
import { createTagInput, getTagKeyForEntity, getTagsList, printTagList, tag_map, compareTagsForSort, removeTagFromMap, importTags, tag_import_setting } from './tags.js';
|
||||
|
||||
/**
|
||||
* Static object representing the actions of the
|
||||
* character context menu override.
|
||||
*/
|
||||
class CharacterContextMenu {
|
||||
/**
|
||||
* Tag one or more characters,
|
||||
* opens a popup.
|
||||
*
|
||||
* @param {Array<number>} selectedCharacters
|
||||
*/
|
||||
static tag = (selectedCharacters) => {
|
||||
characterGroupOverlay.bulkTagPopupHandler.show(selectedCharacters);
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate one or more characters
|
||||
*
|
||||
* @param {number} characterId
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
static duplicate = async (characterId) => {
|
||||
const character = CharacterContextMenu.#getCharacter(characterId);
|
||||
const body = { avatar_url: character.avatar };
|
||||
|
||||
const result = await fetch('/api/characters/duplicate', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error('Character not duplicated');
|
||||
}
|
||||
|
||||
const data = await result.json();
|
||||
await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path });
|
||||
};
|
||||
|
||||
/**
|
||||
* Favorite a character
|
||||
* and highlight it.
|
||||
*
|
||||
* @param {number} characterId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static favorite = async (characterId) => {
|
||||
const character = CharacterContextMenu.#getCharacter(characterId);
|
||||
const newFavState = !character.data.extensions.fav;
|
||||
|
||||
const data = {
|
||||
name: character.name,
|
||||
avatar: character.avatar,
|
||||
data: {
|
||||
extensions: {
|
||||
fav: newFavState,
|
||||
},
|
||||
},
|
||||
fav: newFavState,
|
||||
};
|
||||
|
||||
const mergeResponse = await fetch('/api/characters/merge-attributes', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!mergeResponse.ok) {
|
||||
mergeResponse.json().then(json => toastr.error(`Character not saved. Error: ${json.message}. Field: ${json.error}`));
|
||||
}
|
||||
|
||||
const element = document.getElementById(`CharID${characterId}`);
|
||||
element.classList.toggle('is_fav');
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert one or more characters to persona,
|
||||
* may open a popup for one or more characters.
|
||||
*
|
||||
* @param {number} characterId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static persona = async (characterId) => void(await convertCharacterToPersona(characterId));
|
||||
|
||||
/**
|
||||
* Delete one or more characters,
|
||||
* opens a popup.
|
||||
*
|
||||
* @param {string|string[]} characterKey
|
||||
* @param {boolean} [deleteChats]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static delete = async (characterKey, deleteChats = false) => {
|
||||
await deleteCharacter(characterKey, { deleteChats: deleteChats });
|
||||
};
|
||||
|
||||
static #getCharacter = (characterId) => characters[characterId] ?? null;
|
||||
|
||||
/**
|
||||
* Show the context menu at the given position
|
||||
*
|
||||
* @param positionX
|
||||
* @param positionY
|
||||
*/
|
||||
static show = (positionX, positionY) => {
|
||||
let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
|
||||
contextMenu.style.left = `${positionX}px`;
|
||||
contextMenu.style.top = `${positionY}px`;
|
||||
|
||||
document.getElementById(BulkEditOverlay.contextMenuId).classList.remove('hidden');
|
||||
|
||||
// Adjust position if context menu is outside of viewport
|
||||
const boundingRect = contextMenu.getBoundingClientRect();
|
||||
if (boundingRect.right > window.innerWidth) {
|
||||
contextMenu.style.left = `${positionX - (boundingRect.right - window.innerWidth)}px`;
|
||||
}
|
||||
if (boundingRect.bottom > window.innerHeight) {
|
||||
contextMenu.style.top = `${positionY - (boundingRect.bottom - window.innerHeight)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hide the context menu
|
||||
*/
|
||||
static hide = () => document.getElementById(BulkEditOverlay.contextMenuId).classList.add('hidden');
|
||||
|
||||
/**
|
||||
* Sets up the context menu for the given overlay
|
||||
*
|
||||
* @param characterGroupOverlay
|
||||
*/
|
||||
constructor(characterGroupOverlay) {
|
||||
const contextMenuItems = [
|
||||
{ id: 'character_context_menu_favorite', callback: characterGroupOverlay.handleContextMenuFavorite },
|
||||
{ id: 'character_context_menu_duplicate', callback: characterGroupOverlay.handleContextMenuDuplicate },
|
||||
{ id: 'character_context_menu_delete', callback: characterGroupOverlay.handleContextMenuDelete },
|
||||
{ id: 'character_context_menu_persona', callback: characterGroupOverlay.handleContextMenuPersona },
|
||||
{ id: 'character_context_menu_tag', callback: characterGroupOverlay.handleContextMenuTag },
|
||||
];
|
||||
|
||||
contextMenuItems.forEach(contextMenuItem => document.getElementById(contextMenuItem.id).addEventListener('click', contextMenuItem.callback));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a tag control not bound to a single character
|
||||
*/
|
||||
class BulkTagPopupHandler {
|
||||
/**
|
||||
* The characters for this popup
|
||||
* @type {number[]}
|
||||
*/
|
||||
characterIds;
|
||||
|
||||
/**
|
||||
* A storage of the current mutual tags, as calculated by getMutualTags()
|
||||
* @type {object[]}
|
||||
*/
|
||||
currentMutualTags;
|
||||
|
||||
/**
|
||||
* Sets up the bulk popup menu handler for the given overlay.
|
||||
*
|
||||
* Characters can be passed in with the show() call.
|
||||
*/
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* Gets the HTML as a string that is going to be the popup for the bulk tag edit
|
||||
*
|
||||
* @returns String containing the html for the popup
|
||||
*/
|
||||
#getHtml = () => {
|
||||
const characterData = JSON.stringify({ characterIds: this.characterIds });
|
||||
return `<div id="bulk_tag_shadow_popup">
|
||||
<div id="bulk_tag_popup" class="wider_dialogue_popup">
|
||||
<div id="bulk_tag_popup_holder">
|
||||
<h3 class="marginBot5">Modify tags of ${this.characterIds.length} characters</h3>
|
||||
<small class="bulk_tags_desc m-b-1">Add or remove the mutual tags of all selected characters. Import all or existing tags for all selected characters.</small>
|
||||
<div id="bulk_tags_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline"></div>
|
||||
<br>
|
||||
<div id="bulk_tags_div" class="marginBot5" data-characters='${characterData}'>
|
||||
<div class="tag_controls">
|
||||
<input id="bulkTagInput" class="text_pole tag_input wide100p margin0" data-i18n="[placeholder]Search / Create Tags" placeholder="Search / Create tags" maxlength="25" />
|
||||
<div class="tags_view menu_button fa-solid fa-tags" title="View all tags" data-i18n="[title]View all tags"></div>
|
||||
</div>
|
||||
<div id="bulkTagList" class="m-t-1 tags"></div>
|
||||
</div>
|
||||
<div id="dialogue_popup_controls" class="m-t-1">
|
||||
<div id="bulk_tag_popup_reset" class="menu_button" title="Remove all tags from the selected characters" data-i18n="[title]Remove all tags from the selected characters">
|
||||
<i class="fa-solid fa-trash-can margin-right-10px"></i>
|
||||
All
|
||||
</div>
|
||||
<div id="bulk_tag_popup_remove_mutual" class="menu_button" title="Remove all mutual tags from the selected characters" data-i18n="[title]Remove all mutual tags from the selected characters">
|
||||
<i class="fa-solid fa-trash-can margin-right-10px"></i>
|
||||
Mutual
|
||||
</div>
|
||||
<div id="bulk_tag_popup_import_all_tags" class="menu_button" title="Import all tags from selected characters" data-i18n="[title]Import all tags from selected characters">
|
||||
Import All
|
||||
</div>
|
||||
<div id="bulk_tag_popup_import_existing_tags" class="menu_button" title="Import existing tags from selected characters" data-i18n="[title]Import existing tags from selected characters">
|
||||
Import Existing
|
||||
</div>
|
||||
<div id="bulk_tag_popup_cancel" class="menu_button" data-i18n="Cancel">Close</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Append and show the tag control
|
||||
*
|
||||
* @param {number[]} characterIds - The characters that are shown inside the popup
|
||||
*/
|
||||
show(characterIds) {
|
||||
// shallow copy character ids persistently into this tooltip
|
||||
this.characterIds = characterIds.slice();
|
||||
|
||||
if (this.characterIds.length == 0) {
|
||||
console.log('No characters selected for bulk edit tags.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', this.#getHtml());
|
||||
|
||||
const entities = this.characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined);
|
||||
buildAvatarList($('#bulk_tags_avatars_block'), entities);
|
||||
|
||||
// Print the tag list with all mutuable tags, marking them as removable. That is the initial fill
|
||||
printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
|
||||
|
||||
// Tag input with resolvable list for the mutual tags to get redrawn, so that newly added tags get sorted correctly
|
||||
createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
|
||||
|
||||
document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this));
|
||||
document.querySelector('#bulk_tag_popup_remove_mutual').addEventListener('click', this.removeMutual.bind(this));
|
||||
document.querySelector('#bulk_tag_popup_cancel').addEventListener('click', this.hide.bind(this));
|
||||
document.querySelector('#bulk_tag_popup_import_all_tags').addEventListener('click', this.importAllTags.bind(this));
|
||||
document.querySelector('#bulk_tag_popup_import_existing_tags').addEventListener('click', this.importExistingTags.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Import existing tags for all selected characters
|
||||
*/
|
||||
async importExistingTags() {
|
||||
for (const characterId of this.characterIds) {
|
||||
await importTags(characters[characterId], { importSetting: tag_import_setting.ONLY_EXISTING });
|
||||
}
|
||||
|
||||
$('#bulkTagList').empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Import all tags for all selected characters
|
||||
*/
|
||||
async importAllTags() {
|
||||
for (const characterId of this.characterIds) {
|
||||
await importTags(characters[characterId], { importSetting: tag_import_setting.ALL });
|
||||
}
|
||||
|
||||
$('#bulkTagList').empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a list of all tags that the provided characters have in common.
|
||||
*
|
||||
* @returns {Array<object>} A list of mutual tags
|
||||
*/
|
||||
getMutualTags() {
|
||||
if (this.characterIds.length == 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.characterIds.length === 1) {
|
||||
// Just use tags of the single character
|
||||
return getTagsList(getTagKeyForEntity(this.characterIds[0]));
|
||||
}
|
||||
|
||||
// Find mutual tags for multiple characters
|
||||
const allTags = this.characterIds.map(cid => getTagsList(getTagKeyForEntity(cid)));
|
||||
const mutualTags = allTags.reduce((mutual, characterTags) =>
|
||||
mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)),
|
||||
);
|
||||
|
||||
this.currentMutualTags = mutualTags.sort(compareTagsForSort);
|
||||
return this.currentMutualTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide and remove the tag control
|
||||
*/
|
||||
hide() {
|
||||
let popupElement = document.querySelector('#bulk_tag_shadow_popup');
|
||||
if (popupElement) {
|
||||
document.body.removeChild(popupElement);
|
||||
}
|
||||
|
||||
// No need to redraw here, all tags actions were redrawn when they happened
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty the tag map for the given characters
|
||||
*/
|
||||
resetTags() {
|
||||
for (const characterId of this.characterIds) {
|
||||
const key = getTagKeyForEntity(characterId);
|
||||
if (key) tag_map[key] = [];
|
||||
}
|
||||
|
||||
$('#bulkTagList').empty();
|
||||
|
||||
printCharactersDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the mutual tags for all given characters
|
||||
*/
|
||||
removeMutual() {
|
||||
const mutualTags = this.getMutualTags();
|
||||
|
||||
for (const characterId of this.characterIds) {
|
||||
for (const tag of mutualTags) {
|
||||
removeTagFromMap(tag.id, characterId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
$('#bulkTagList').empty();
|
||||
|
||||
printCharactersDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
class BulkEditOverlayState {
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
static browse = 0;
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
static select = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement a SingletonPattern, allowing access to the group overlay instance
|
||||
* from everywhere via (new CharacterGroupOverlay())
|
||||
*
|
||||
* @type {Readonly<BulkEditOverlay>}
|
||||
*/
|
||||
let bulkEditOverlayInstance = null;
|
||||
|
||||
class BulkEditOverlay {
|
||||
static containerId = 'rm_print_characters_block';
|
||||
static contextMenuId = 'character_context_menu';
|
||||
static characterClass = 'character_select';
|
||||
static groupClass = 'group_select';
|
||||
static bogusFolderClass = 'bogus_folder_select';
|
||||
static selectModeClass = 'group_overlay_mode_select';
|
||||
static selectedClass = 'character_selected';
|
||||
static legacySelectedClass = 'bulk_select_checkbox';
|
||||
static bulkSelectedCountId = 'bulkSelectedCount';
|
||||
|
||||
static longPressDelay = 2500;
|
||||
|
||||
#state = BulkEditOverlayState.browse;
|
||||
#longPress = false;
|
||||
#stateChangeCallbacks = [];
|
||||
#selectedCharacters = [];
|
||||
#bulkTagPopupHandler = new BulkTagPopupHandler();
|
||||
|
||||
/**
|
||||
* @typedef {object} LastSelected - An object noting the last selected character and its state.
|
||||
* @property {number} [characterId] - The character id of the last selected character.
|
||||
* @property {boolean} [select] - The selected state of the last selected character. <c>true</c> if it was selected, <c>false</c> if it was deselected.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {LastSelected} - An object noting the last selected character and its state.
|
||||
*/
|
||||
lastSelected = { characterId: undefined, select: undefined };
|
||||
|
||||
/**
|
||||
* Locks other pointer actions when the context menu is open
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#contextMenuOpen = false;
|
||||
|
||||
/**
|
||||
* Whether the next character select should be skipped
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#cancelNextToggle = false;
|
||||
|
||||
/**
|
||||
* @type HTMLElement
|
||||
*/
|
||||
container = null;
|
||||
|
||||
get state() {
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
set state(newState) {
|
||||
if (this.#state === newState) return;
|
||||
|
||||
eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE, newState)
|
||||
.then(() => {
|
||||
this.#state = newState;
|
||||
eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.state);
|
||||
});
|
||||
}
|
||||
|
||||
get isLongPress() {
|
||||
return this.#longPress;
|
||||
}
|
||||
|
||||
set isLongPress(longPress) {
|
||||
this.#longPress = longPress;
|
||||
}
|
||||
|
||||
get stateChangeCallbacks() {
|
||||
return this.#stateChangeCallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {number[]}
|
||||
*/
|
||||
get selectedCharacters() {
|
||||
return this.#selectedCharacters;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance of the bulk tag popup handler that handles tagging of all selected characters
|
||||
*
|
||||
* @returns {BulkTagPopupHandler}
|
||||
*/
|
||||
get bulkTagPopupHandler() {
|
||||
return this.#bulkTagPopupHandler;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (bulkEditOverlayInstance instanceof BulkEditOverlay)
|
||||
return bulkEditOverlayInstance;
|
||||
|
||||
this.container = document.getElementById(BulkEditOverlay.containerId);
|
||||
|
||||
eventSource.on(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.handleStateChange);
|
||||
bulkEditOverlayInstance = Object.freeze(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the overlay to browse mode
|
||||
*/
|
||||
browseState = () => this.state = BulkEditOverlayState.browse;
|
||||
|
||||
/**
|
||||
* Set the overlay to select mode
|
||||
*/
|
||||
selectState = () => this.state = BulkEditOverlayState.select;
|
||||
|
||||
/**
|
||||
* Set up a Sortable grid for the loaded page
|
||||
*/
|
||||
onPageLoad = () => {
|
||||
this.browseState();
|
||||
|
||||
const elements = this.#getEnabledElements();
|
||||
elements.forEach(element => element.addEventListener('touchstart', this.handleHold));
|
||||
elements.forEach(element => element.addEventListener('mousedown', this.handleHold));
|
||||
elements.forEach(element => element.addEventListener('contextmenu', this.handleDefaultContextMenu));
|
||||
|
||||
elements.forEach(element => element.addEventListener('touchend', this.handleLongPressEnd));
|
||||
elements.forEach(element => element.addEventListener('mouseup', this.handleLongPressEnd));
|
||||
elements.forEach(element => element.addEventListener('dragend', this.handleLongPressEnd));
|
||||
elements.forEach(element => element.addEventListener('touchmove', this.handleLongPressEnd));
|
||||
|
||||
// Cohee: It only triggers when clicking on a margin between the elements?
|
||||
// Feel free to fix or remove this, I'm not sure how to.
|
||||
//this.container.addEventListener('click', this.handleCancelClick);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle state changes
|
||||
*
|
||||
*
|
||||
*/
|
||||
handleStateChange = () => {
|
||||
switch (this.state) {
|
||||
case BulkEditOverlayState.browse:
|
||||
this.container.classList.remove(BulkEditOverlay.selectModeClass);
|
||||
this.#contextMenuOpen = false;
|
||||
this.#enableClickEventsForCharacters();
|
||||
this.#enableClickEventsForGroups();
|
||||
this.clearSelectedCharacters();
|
||||
this.disableContextMenu();
|
||||
this.#disableBulkEditButtonHighlight();
|
||||
CharacterContextMenu.hide();
|
||||
break;
|
||||
case BulkEditOverlayState.select:
|
||||
this.container.classList.add(BulkEditOverlay.selectModeClass);
|
||||
this.#disableClickEventsForCharacters();
|
||||
this.#disableClickEventsForGroups();
|
||||
this.enableContextMenu();
|
||||
this.#enableBulkEditButtonHighlight();
|
||||
break;
|
||||
}
|
||||
|
||||
this.stateChangeCallbacks.forEach(callback => callback(this.state));
|
||||
};
|
||||
|
||||
/**
|
||||
* Block the browsers native context menu and
|
||||
* set a click event to hide the custom context menu.
|
||||
*/
|
||||
enableContextMenu = () => {
|
||||
this.container.addEventListener('contextmenu', this.handleContextMenuShow);
|
||||
document.addEventListener('click', this.handleContextMenuHide);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove event listeners, allowing the native browser context
|
||||
* menu to be opened.
|
||||
*/
|
||||
disableContextMenu = () => {
|
||||
this.container.removeEventListener('contextmenu', this.handleContextMenuShow);
|
||||
document.removeEventListener('click', this.handleContextMenuHide);
|
||||
};
|
||||
|
||||
handleDefaultContextMenu = (event) => {
|
||||
if (this.isLongPress) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens menu on long-press.
|
||||
*
|
||||
* @param event - Pointer event
|
||||
*/
|
||||
handleHold = (event) => {
|
||||
if (0 !== event.button && event.type !== 'touchstart') return;
|
||||
if (this.#contextMenuOpen) {
|
||||
this.#contextMenuOpen = false;
|
||||
this.#cancelNextToggle = true;
|
||||
CharacterContextMenu.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancel = false;
|
||||
|
||||
const cancelHold = (event) => cancel = true;
|
||||
this.container.addEventListener('mouseup', cancelHold);
|
||||
this.container.addEventListener('touchend', cancelHold);
|
||||
|
||||
this.isLongPress = true;
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.isLongPress && !cancel) {
|
||||
if (this.state === BulkEditOverlayState.browse) {
|
||||
this.selectState();
|
||||
} else if (this.state === BulkEditOverlayState.select) {
|
||||
this.#contextMenuOpen = true;
|
||||
const [x, y] = this.#getContextMenuPosition(event);
|
||||
CharacterContextMenu.show(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
this.container.removeEventListener('mouseup', cancelHold);
|
||||
this.container.removeEventListener('touchend', cancelHold);
|
||||
}, BulkEditOverlay.longPressDelay);
|
||||
};
|
||||
|
||||
handleLongPressEnd = (event) => {
|
||||
this.isLongPress = false;
|
||||
if (this.#contextMenuOpen) event.stopPropagation();
|
||||
};
|
||||
|
||||
handleCancelClick = () => {
|
||||
if (false === this.#contextMenuOpen) this.state = BulkEditOverlayState.browse;
|
||||
this.#contextMenuOpen = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the position of the mouse/touch location
|
||||
*
|
||||
* @param event
|
||||
* @returns {(boolean|number|*)[]}
|
||||
*/
|
||||
#getContextMenuPosition = (event) => [
|
||||
event.clientX || event.touches[0].clientX,
|
||||
event.clientY || event.touches[0].clientY,
|
||||
];
|
||||
|
||||
#stopEventPropagation = (event) => {
|
||||
if (this.#contextMenuOpen) {
|
||||
this.handleContextMenuHide(event);
|
||||
}
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
#enableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.removeEventListener('click', this.#stopEventPropagation));
|
||||
|
||||
#disableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.addEventListener('click', this.#stopEventPropagation));
|
||||
|
||||
#enableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.removeEventListener('click', this.toggleCharacterSelected));
|
||||
|
||||
#disableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.addEventListener('click', this.toggleCharacterSelected));
|
||||
|
||||
#enableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.add('bulk_edit_overlay_active');
|
||||
|
||||
#disableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.remove('bulk_edit_overlay_active');
|
||||
|
||||
#getEnabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.characterClass)];
|
||||
|
||||
#getDisabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.groupClass), ...this.container.getElementsByClassName(BulkEditOverlay.bogusFolderClass)];
|
||||
|
||||
toggleCharacterSelected = event => {
|
||||
event.stopPropagation();
|
||||
|
||||
const character = event.currentTarget;
|
||||
|
||||
if (!this.#contextMenuOpen && !this.#cancelNextToggle) {
|
||||
if (event.shiftKey) {
|
||||
// Shift click might have selected text that we don't want to. Unselect it.
|
||||
document.getSelection().removeAllRanges();
|
||||
|
||||
this.handleShiftClick(character);
|
||||
} else {
|
||||
this.toggleSingleCharacter(character);
|
||||
}
|
||||
}
|
||||
|
||||
this.#cancelNextToggle = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* When shift click was held down, this function handles the multi select of characters in a single click.
|
||||
*
|
||||
* If the last clicked character was deselected, and the current one was deselected too, it will deselect all currently selected characters between those two.
|
||||
* If the last clicked character was selected, and the current one was selected too, it will select all currently not selected characters between those two.
|
||||
* If the states do not match, nothing will happen.
|
||||
*
|
||||
* @param {HTMLElement} currentCharacter - The html element of the currently toggled character
|
||||
*/
|
||||
handleShiftClick = (currentCharacter) => {
|
||||
const characterId = Number(currentCharacter.getAttribute('data-chid'));
|
||||
const select = !this.selectedCharacters.includes(characterId);
|
||||
|
||||
if (this.lastSelected.characterId >= 0 && this.lastSelected.select !== undefined) {
|
||||
// Only if select state and the last select state match we execute the range select
|
||||
if (select === this.lastSelected.select) {
|
||||
this.toggleCharactersInRange(currentCharacter, select);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the selection of a given characters
|
||||
*
|
||||
* @param {HTMLElement} character - The html element of a character
|
||||
* @param {object} param1 - Optional params
|
||||
* @param {boolean} [param1.markState] - Whether the toggle of this character should be remembered as the last done toggle
|
||||
*/
|
||||
toggleSingleCharacter = (character, { markState = true } = {}) => {
|
||||
const characterId = Number(character.getAttribute('data-chid'));
|
||||
|
||||
const select = !this.selectedCharacters.includes(characterId);
|
||||
const legacyBulkEditCheckbox = /** @type {HTMLInputElement} */ (character.querySelector('.' + BulkEditOverlay.legacySelectedClass));
|
||||
|
||||
if (select) {
|
||||
character.classList.add(BulkEditOverlay.selectedClass);
|
||||
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true;
|
||||
this.#selectedCharacters.push(characterId);
|
||||
} else {
|
||||
character.classList.remove(BulkEditOverlay.selectedClass);
|
||||
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
|
||||
this.#selectedCharacters = this.#selectedCharacters.filter(item => characterId !== item);
|
||||
}
|
||||
|
||||
this.updateSelectedCount();
|
||||
|
||||
if (markState) {
|
||||
this.lastSelected.characterId = characterId;
|
||||
this.lastSelected.select = select;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the selected count element with the current count
|
||||
*
|
||||
* @param {number} [countOverride] - optional override for a manual number to set
|
||||
*/
|
||||
updateSelectedCount = (countOverride = undefined) => {
|
||||
const count = countOverride ?? this.selectedCharacters.length;
|
||||
$(`#${BulkEditOverlay.bulkSelectedCountId}`).text(count).attr('title', `${count} characters selected`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the selection of characters in a given range.
|
||||
* The range is provided by the given character and the last selected one remembered in the selection state.
|
||||
*
|
||||
* @param {HTMLElement} currentCharacter - The html element of the currently toggled character
|
||||
* @param {boolean} select - <c>true</c> if the characters in the range are to be selected, <c>false</c> if deselected
|
||||
*/
|
||||
toggleCharactersInRange = (currentCharacter, select) => {
|
||||
const currentCharacterId = Number(currentCharacter.getAttribute('data-chid'));
|
||||
const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass));
|
||||
|
||||
const startIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === Number(this.lastSelected.characterId));
|
||||
const endIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === currentCharacterId);
|
||||
|
||||
for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) {
|
||||
const character = characters[i];
|
||||
const characterId = Number(character.getAttribute('data-chid'));
|
||||
const isCharacterSelected = this.selectedCharacters.includes(characterId);
|
||||
|
||||
// Only toggle the character if it wasn't on the state we have are toggling towards.
|
||||
// Also doing a weird type check, because typescript checker doesn't like the return of 'querySelectorAll'.
|
||||
if ((select && !isCharacterSelected || !select && isCharacterSelected) && character instanceof HTMLElement) {
|
||||
this.toggleSingleCharacter(character, { markState: currentCharacterId == characterId });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleContextMenuShow = (event) => {
|
||||
event.preventDefault();
|
||||
const [x,y] = this.#getContextMenuPosition(event);
|
||||
CharacterContextMenu.show(x, y);
|
||||
this.#contextMenuOpen = true;
|
||||
};
|
||||
|
||||
handleContextMenuHide = (event) => {
|
||||
let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
|
||||
if (false === contextMenu.contains(event.target)) {
|
||||
CharacterContextMenu.hide();
|
||||
this.#contextMenuOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Concurrently handle character favorite requests.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
handleContextMenuFavorite = async () => {
|
||||
const promises = [];
|
||||
|
||||
for (const characterId of this.selectedCharacters) {
|
||||
promises.push(CharacterContextMenu.favorite(characterId));
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
await getCharacters();
|
||||
await favsToHotswap();
|
||||
this.browseState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Concurrently handle character duplicate requests.
|
||||
*
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
handleContextMenuDuplicate = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.duplicate(characterId)))
|
||||
.then(() => getCharacters())
|
||||
.then(() => this.browseState());
|
||||
|
||||
/**
|
||||
* Sequentially handle all character-to-persona conversions.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
handleContextMenuPersona = async () => {
|
||||
for (const characterId of this.selectedCharacters) {
|
||||
await CharacterContextMenu.persona(characterId);
|
||||
}
|
||||
|
||||
this.browseState();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the HTML as a string that is displayed inside the popup for the bulk delete
|
||||
*
|
||||
* @param {Array<number>} characterIds - The characters that are shown inside the popup
|
||||
* @returns String containing the html for the popup content
|
||||
*/
|
||||
static #getDeletePopupContentHtml = (characterIds) => {
|
||||
return `
|
||||
<h3 class="marginBot5">Delete ${characterIds.length} characters?</h3>
|
||||
<span class="bulk_delete_note">
|
||||
<i class="fa-solid fa-triangle-exclamation warning margin-r5"></i>
|
||||
<b>THIS IS PERMANENT!</b>
|
||||
</span>
|
||||
<div id="bulk_delete_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline m-t-1"></div>
|
||||
<br>
|
||||
<div id="bulk_delete_options" class="m-b-1">
|
||||
<label for="del_char_checkbox" class="checkbox_label justifyCenter">
|
||||
<input type="checkbox" id="del_char_checkbox" />
|
||||
<span>Also delete the chat files</span>
|
||||
</label>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request user input before concurrently handle deletion
|
||||
* requests.
|
||||
*
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
handleContextMenuDelete = () => {
|
||||
const characterIds = this.selectedCharacters;
|
||||
const popupContent = $(BulkEditOverlay.#getDeletePopupContentHtml(characterIds));
|
||||
const checkbox = popupContent.find('#del_char_checkbox');
|
||||
const promise = callGenericPopup(popupContent, POPUP_TYPE.CONFIRM)
|
||||
.then((accept) => {
|
||||
if (!accept) return;
|
||||
|
||||
const deleteChats = checkbox.prop('checked') ?? false;
|
||||
|
||||
showLoader();
|
||||
const toast = toastr.info('We\'re deleting your characters, please wait...', 'Working on it');
|
||||
const avatarList = characterIds.map(id => characters[id]?.avatar).filter(a => a);
|
||||
return CharacterContextMenu.delete(avatarList, deleteChats)
|
||||
.then(() => this.browseState())
|
||||
.finally(() => {
|
||||
toastr.clear(toast);
|
||||
hideLoader();
|
||||
});
|
||||
});
|
||||
|
||||
// At this moment the popup is already changed in the dom, but not yet closed/resolved. We build the avatar list here
|
||||
const entities = characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined);
|
||||
buildAvatarList($('#bulk_delete_avatars_block'), entities);
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attaches and opens the tag menu
|
||||
*/
|
||||
handleContextMenuTag = () => {
|
||||
CharacterContextMenu.tag(this.selectedCharacters);
|
||||
this.browseState();
|
||||
};
|
||||
|
||||
addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback);
|
||||
|
||||
/**
|
||||
* Clears internal character storage and
|
||||
* removes visual highlight.
|
||||
*/
|
||||
clearSelectedCharacters = () => {
|
||||
document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.selectedClass)
|
||||
.forEach(element => element.classList.remove(BulkEditOverlay.selectedClass));
|
||||
this.selectedCharacters.length = 0;
|
||||
};
|
||||
}
|
||||
|
||||
export { BulkEditOverlayState, CharacterContextMenu, BulkEditOverlay };
|
||||
2146
web-app/public/scripts/PromptManager.js
Normal file
2146
web-app/public/scripts/PromptManager.js
Normal file
File diff suppressed because it is too large
Load Diff
1285
web-app/public/scripts/RossAscends-mods.js
Normal file
1285
web-app/public/scripts/RossAscends-mods.js
Normal file
File diff suppressed because it is too large
Load Diff
130
web-app/public/scripts/a11y.js
Normal file
130
web-app/public/scripts/a11y.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Shared module between login and main app.
|
||||
* Be careful what you import!
|
||||
*/
|
||||
|
||||
const buttonSelectors = [
|
||||
'.menu_button',
|
||||
'.right_menu_button',
|
||||
'.mes_button',
|
||||
'.drawer-icon',
|
||||
'.inline-drawer-icon',
|
||||
'.swipe_left',
|
||||
'.swipe_right',
|
||||
'.character_select',
|
||||
'.tags .tag',
|
||||
'.jg-menu .jg-button',
|
||||
'.bg_example .mobile-only-menu-toggle',
|
||||
'.paginationjs-pages li a',
|
||||
].join(', ');
|
||||
|
||||
const listSelectors = [
|
||||
'.options-content',
|
||||
'.list-group',
|
||||
'#rm_print_characters_block',
|
||||
'#rm_group_members',
|
||||
'#rm_group_add_members',
|
||||
'.tag_view_list_tags',
|
||||
'.secretKeyManagerList',
|
||||
'.recentChatList',
|
||||
'.dataMaidCategoryContent',
|
||||
'#userList',
|
||||
'.bg_list',
|
||||
].join(', ');
|
||||
|
||||
const listItemSelectors = [
|
||||
'.options-content .list-group-item',
|
||||
'.list-group .list-group-item',
|
||||
'#rm_print_characters_block .entity_block',
|
||||
'#rm_group_members .group_member',
|
||||
'#rm_group_add_members .group_member',
|
||||
'.tag_view_list_tags .tag_view_item',
|
||||
'.secretKeyManagerList .secretKeyManagerItem',
|
||||
'.recentChatList .recentChat',
|
||||
'.dataMaidCategoryContent .dataMaidItem',
|
||||
'#userList .userSelect',
|
||||
'.bg_list .bg_example',
|
||||
].join(', ');
|
||||
|
||||
const toolbarSelectors = [
|
||||
'.jg-menu',
|
||||
].join(', ');
|
||||
|
||||
const tabListSelectors = [
|
||||
'#bg_tabs .bg_tabs_list',
|
||||
].join(', ');
|
||||
|
||||
const tabItemSelectors = [
|
||||
'#bg_tabs .bg_tabs_list .bg_tab_button',
|
||||
].join(', ');
|
||||
|
||||
/** @type {Record<string, (element: Element) => void>} */
|
||||
const a11yRules = {
|
||||
[buttonSelectors]: (element) => {
|
||||
element.setAttribute('role', 'button');
|
||||
},
|
||||
[listSelectors]: (element) => {
|
||||
element.setAttribute('role', 'list');
|
||||
},
|
||||
[listItemSelectors]: (element) => {
|
||||
element.setAttribute('role', 'listitem');
|
||||
},
|
||||
[toolbarSelectors]: (element) => {
|
||||
element.setAttribute('role', 'toolbar');
|
||||
},
|
||||
[tabListSelectors]: (element) => {
|
||||
element.setAttribute('role', 'tablist');
|
||||
},
|
||||
[tabItemSelectors]: (element) => {
|
||||
element.setAttribute('role', 'tab');
|
||||
},
|
||||
'#toast-container .toast': (element) => {
|
||||
element.setAttribute('role', 'status');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply accessibility rules to an element.
|
||||
* @param {Element} element Element to process.
|
||||
*/
|
||||
function applyA11yRules(element) {
|
||||
try {
|
||||
for (const [selector, rule] of Object.entries(a11yRules)) {
|
||||
// Apply if the element directly matches the selector
|
||||
if (element.matches(selector)) {
|
||||
rule(element);
|
||||
}
|
||||
// Apply the rule to descendants
|
||||
element.querySelectorAll(selector).forEach(rule);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying accessibility rules to element:', element, error);
|
||||
}
|
||||
}
|
||||
|
||||
function setAccessibilityObserver() {
|
||||
// Apply for existing elements
|
||||
applyA11yRules(document.body);
|
||||
|
||||
// Setup observer for dynamic content
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'childList') {
|
||||
for (const addedNode of mutation.addedNodes) {
|
||||
if (addedNode instanceof Element && addedNode.nodeType === Node.ELEMENT_NODE) {
|
||||
applyA11yRules(addedNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function initAccessibility() {
|
||||
setAccessibilityObserver();
|
||||
}
|
||||
605
web-app/public/scripts/audio-player.js
Normal file
605
web-app/public/scripts/audio-player.js
Normal file
@@ -0,0 +1,605 @@
|
||||
import { formatTime } from './utils.js';
|
||||
|
||||
export class AudioPlayer {
|
||||
/**
|
||||
* Creates an audio player instance
|
||||
* @param {HTMLElement} audioElement - The audio element to control
|
||||
* @param {HTMLElement} containerElement - The container element with player controls
|
||||
* @param {Object} options - Configuration options
|
||||
*/
|
||||
constructor(audioElement, containerElement, options = {}) {
|
||||
if (!(audioElement instanceof HTMLAudioElement)) {
|
||||
throw new Error('First argument must be an HTMLAudioElement');
|
||||
}
|
||||
if (!(containerElement instanceof HTMLElement)) {
|
||||
throw new Error('Second argument must be an HTMLElement');
|
||||
}
|
||||
|
||||
this.audio = audioElement;
|
||||
this.container = containerElement;
|
||||
this.options = {
|
||||
title: '',
|
||||
autoplay: false,
|
||||
volume: 1.0,
|
||||
onPlay: null,
|
||||
onPause: null,
|
||||
onEnded: null,
|
||||
onTimeUpdate: null,
|
||||
onVolumeChange: null,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.isDragging = false;
|
||||
this.isDestroyed = false;
|
||||
|
||||
// Store bound event handlers for cleanup
|
||||
this.boundHandlers = {
|
||||
// Audio event handlers
|
||||
audioLoadedMetadata: this.onAudioLoadedMetadata.bind(this),
|
||||
audioTimeUpdate: this.onAudioTimeUpdate.bind(this),
|
||||
audioPlay: this.onAudioPlay.bind(this),
|
||||
audioPause: this.onAudioPause.bind(this),
|
||||
audioEnded: this.onAudioEnded.bind(this),
|
||||
audioVolumeChange: this.onAudioVolumeChange.bind(this),
|
||||
// Control event handlers
|
||||
playPauseClick: this.onPlayPauseClick.bind(this),
|
||||
volumeClick: this.onVolumeClick.bind(this),
|
||||
volumeInput: this.onVolumeInput.bind(this),
|
||||
progressMouseDown: this.onProgressMouseDown.bind(this),
|
||||
progressClick: this.onProgressClick.bind(this),
|
||||
progressMouseMove: this.onProgressMouseMove.bind(this),
|
||||
documentMouseMove: this.onDocumentMouseMove.bind(this),
|
||||
documentMouseUp: this.onDocumentMouseUp.bind(this),
|
||||
};
|
||||
|
||||
// MutationObserver for DOM cleanup detection
|
||||
this.observer = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the audio player by setting up elements, events, and initial state
|
||||
* @returns {void}
|
||||
*/
|
||||
init() {
|
||||
this.findElements();
|
||||
this.bindEvents();
|
||||
this.setupDOMObserver();
|
||||
|
||||
if (this.options.title) {
|
||||
this.setTitle(this.options.title);
|
||||
} else if (this.audio.title) {
|
||||
this.setTitle(this.audio.title);
|
||||
} else if (this.audio.src) {
|
||||
const srcParts = this.audio.src.split('/');
|
||||
this.setTitle(decodeURIComponent(srcParts[srcParts.length - 1]));
|
||||
}
|
||||
|
||||
if (this.options.autoplay) {
|
||||
this.play();
|
||||
}
|
||||
|
||||
this.setVolume(this.options.volume);
|
||||
|
||||
// Initialize time displays
|
||||
this.updateTimeDisplays();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and caches all required DOM elements within the container
|
||||
* @returns {void}
|
||||
*/
|
||||
findElements() {
|
||||
this.elements = {
|
||||
title: this.container.querySelector('.audio-player-title'),
|
||||
playPauseBtn: this.container.querySelector('.audio-player-play-pause'),
|
||||
currentTime: this.container.querySelector('.audio-player-current-time'),
|
||||
totalTime: this.container.querySelector('.audio-player-total-time'),
|
||||
progress: this.container.querySelector('.audio-player-progress'),
|
||||
progressBar: this.container.querySelector('.audio-player-progress-bar'),
|
||||
volumeBtn: this.container.querySelector('.audio-player-volume'),
|
||||
};
|
||||
|
||||
// Validate required elements
|
||||
const requiredElements = ['playPauseBtn', 'currentTime', 'totalTime', 'progress', 'progressBar', 'volumeBtn'];
|
||||
for (const key of requiredElements) {
|
||||
if (!this.elements[key]) {
|
||||
console.warn(`AudioPlayer: Required element .audio-player-${key.replace(/([A-Z])/g, '-$1').toLowerCase()} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a MutationObserver to detect when audio or container elements are removed from DOM
|
||||
* @returns {void}
|
||||
*/
|
||||
setupDOMObserver() {
|
||||
// Watch for removal of audio or container from DOM
|
||||
this.observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (node === this.audio || node === this.container ||
|
||||
node.contains?.(this.audio) || node.contains?.(this.container)) {
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Observe the parent nodes
|
||||
const chatParent = this.audio.closest('#chat') ?? document.body;
|
||||
|
||||
if (chatParent) {
|
||||
this.observer.observe(chatParent, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds all event listeners to audio and control elements
|
||||
* @returns {void}
|
||||
*/
|
||||
bindEvents() {
|
||||
// Audio events
|
||||
this.audio.addEventListener('loadedmetadata', this.boundHandlers.audioLoadedMetadata);
|
||||
this.audio.addEventListener('timeupdate', this.boundHandlers.audioTimeUpdate);
|
||||
this.audio.addEventListener('play', this.boundHandlers.audioPlay);
|
||||
this.audio.addEventListener('pause', this.boundHandlers.audioPause);
|
||||
this.audio.addEventListener('ended', this.boundHandlers.audioEnded);
|
||||
this.audio.addEventListener('volumechange', this.boundHandlers.audioVolumeChange);
|
||||
|
||||
// Control events
|
||||
if (this.elements.playPauseBtn) {
|
||||
this.elements.playPauseBtn.addEventListener('click', this.boundHandlers.playPauseClick);
|
||||
}
|
||||
if (this.elements.volumeBtn) {
|
||||
this.elements.volumeBtn.addEventListener('click', this.boundHandlers.volumeClick);
|
||||
}
|
||||
if (this.elements.progress) {
|
||||
this.elements.progress.addEventListener('mousedown', this.boundHandlers.progressMouseDown);
|
||||
this.elements.progress.addEventListener('click', this.boundHandlers.progressClick);
|
||||
this.elements.progress.addEventListener('mousemove', this.boundHandlers.progressMouseMove);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all event listeners from audio and control elements
|
||||
* @returns {void}
|
||||
*/
|
||||
unbindEvents() {
|
||||
// Audio events
|
||||
this.audio.removeEventListener('loadedmetadata', this.boundHandlers.audioLoadedMetadata);
|
||||
this.audio.removeEventListener('timeupdate', this.boundHandlers.audioTimeUpdate);
|
||||
this.audio.removeEventListener('play', this.boundHandlers.audioPlay);
|
||||
this.audio.removeEventListener('pause', this.boundHandlers.audioPause);
|
||||
this.audio.removeEventListener('ended', this.boundHandlers.audioEnded);
|
||||
this.audio.removeEventListener('volumechange', this.boundHandlers.audioVolumeChange);
|
||||
|
||||
// Control events
|
||||
if (this.elements.playPauseBtn) {
|
||||
this.elements.playPauseBtn.removeEventListener('click', this.boundHandlers.playPauseClick);
|
||||
}
|
||||
if (this.elements.volumeBtn) {
|
||||
this.elements.volumeBtn.removeEventListener('click', this.boundHandlers.volumeClick);
|
||||
}
|
||||
if (this.elements.progress) {
|
||||
this.elements.progress.removeEventListener('mousedown', this.boundHandlers.progressMouseDown);
|
||||
this.elements.progress.removeEventListener('click', this.boundHandlers.progressClick);
|
||||
this.elements.progress.removeEventListener('mousemove', this.boundHandlers.progressMouseMove);
|
||||
}
|
||||
|
||||
// Document events
|
||||
document.removeEventListener('mousemove', this.boundHandlers.documentMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundHandlers.documentMouseUp);
|
||||
}
|
||||
|
||||
// Audio event handlers
|
||||
/**
|
||||
* Handles the audio element's loadedmetadata event
|
||||
* @returns {void}
|
||||
*/
|
||||
onAudioLoadedMetadata() {
|
||||
if (this.isDestroyed) return;
|
||||
this.updateTimeDisplays();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the audio element's timeupdate event
|
||||
* @returns {void}
|
||||
*/
|
||||
onAudioTimeUpdate() {
|
||||
if (this.isDestroyed || this.isDragging) return;
|
||||
|
||||
const percent = (this.audio.currentTime / this.audio.duration) * 100 || 0;
|
||||
if (this.elements.progressBar) {
|
||||
/** @type {HTMLElement} */ (this.elements.progressBar).style.width = percent + '%';
|
||||
}
|
||||
if (this.elements.currentTime) {
|
||||
this.elements.currentTime.textContent = formatTime(this.audio.currentTime);
|
||||
}
|
||||
|
||||
if (typeof this.options.onTimeUpdate === 'function') {
|
||||
this.options.onTimeUpdate.call(this, this.audio.currentTime, this.audio.duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the audio element's play event
|
||||
* @returns {void}
|
||||
*/
|
||||
onAudioPlay() {
|
||||
if (this.isDestroyed) return;
|
||||
|
||||
if (this.elements.playPauseBtn) {
|
||||
this.elements.playPauseBtn.classList.remove('fa-play');
|
||||
this.elements.playPauseBtn.classList.add('fa-pause');
|
||||
this.elements.playPauseBtn.setAttribute('title', 'Pause');
|
||||
}
|
||||
|
||||
if (typeof this.options.onPlay === 'function') {
|
||||
this.options.onPlay.call(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the audio element's pause event
|
||||
* @returns {void}
|
||||
*/
|
||||
onAudioPause() {
|
||||
if (this.isDestroyed) return;
|
||||
|
||||
if (this.elements.playPauseBtn) {
|
||||
this.elements.playPauseBtn.classList.remove('fa-pause');
|
||||
this.elements.playPauseBtn.classList.add('fa-play');
|
||||
this.elements.playPauseBtn.setAttribute('title', 'Play');
|
||||
}
|
||||
|
||||
if (typeof this.options.onPause === 'function') {
|
||||
this.options.onPause.call(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the audio element's ended event
|
||||
* @returns {void}
|
||||
*/
|
||||
onAudioEnded() {
|
||||
if (this.isDestroyed) return;
|
||||
|
||||
if (this.elements.playPauseBtn) {
|
||||
this.elements.playPauseBtn.classList.remove('fa-pause');
|
||||
this.elements.playPauseBtn.classList.add('fa-play');
|
||||
this.elements.playPauseBtn.setAttribute('title', 'Play');
|
||||
}
|
||||
|
||||
if (typeof this.options.onEnded === 'function') {
|
||||
this.options.onEnded.call(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the audio element's volumechange event
|
||||
* @returns {void}
|
||||
*/
|
||||
onAudioVolumeChange() {
|
||||
if (this.isDestroyed) return;
|
||||
|
||||
this.updateVolumeIcon();
|
||||
|
||||
if (typeof this.options.onVolumeChange === 'function') {
|
||||
this.options.onVolumeChange.call(this, this.audio.volume, this.audio.muted);
|
||||
}
|
||||
}
|
||||
|
||||
// Control event handlers
|
||||
/**
|
||||
* Handles click events on the play/pause button
|
||||
* @param {MouseEvent} e - The click event
|
||||
* @returns {void}
|
||||
*/
|
||||
onPlayPauseClick(e) {
|
||||
e.preventDefault();
|
||||
this.togglePlay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles click events on the volume button
|
||||
* @param {MouseEvent} e - The click event
|
||||
* @returns {void}
|
||||
*/
|
||||
onVolumeClick(e) {
|
||||
e.preventDefault();
|
||||
this.toggleMute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles input events on the volume slider
|
||||
* @param {InputEvent} e - The input event
|
||||
* @returns {void}
|
||||
*/
|
||||
onVolumeInput(e) {
|
||||
if (!(e.target instanceof HTMLInputElement)) return;
|
||||
const value = parseFloat(e.target.value);
|
||||
this.setVolume(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mousedown events on the progress bar
|
||||
* @param {MouseEvent} e - The mousedown event
|
||||
* @returns {void}
|
||||
*/
|
||||
onProgressMouseDown(e) {
|
||||
this.isDragging = true;
|
||||
this.updateProgress(e);
|
||||
document.addEventListener('mousemove', this.boundHandlers.documentMouseMove);
|
||||
document.addEventListener('mouseup', this.boundHandlers.documentMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles click events on the progress bar
|
||||
* @param {MouseEvent} e - The click event
|
||||
* @returns {void}
|
||||
*/
|
||||
onProgressClick(e) {
|
||||
if (!this.isDragging) {
|
||||
this.updateProgress(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mousemove on the progress bar (no-op if dragging)
|
||||
* @param {MouseEvent} e - The mousemove event
|
||||
* @returns {void}
|
||||
*/
|
||||
onProgressMouseMove(e) {
|
||||
if (!this.isDragging) {
|
||||
this.updateProgressTitle(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles document mousemove events during progress bar dragging
|
||||
* @param {MouseEvent} e - The mousemove event
|
||||
* @returns {void}
|
||||
*/
|
||||
onDocumentMouseMove(e) {
|
||||
if (this.isDragging) {
|
||||
this.updateProgress(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles document mouseup events to end progress bar dragging
|
||||
* @returns {void}
|
||||
*/
|
||||
onDocumentMouseUp() {
|
||||
if (this.isDragging) {
|
||||
this.isDragging = false;
|
||||
document.removeEventListener('mousemove', this.boundHandlers.documentMouseMove);
|
||||
document.removeEventListener('mouseup', this.boundHandlers.documentMouseUp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the progress bar position and seeks audio based on mouse position
|
||||
* @param {MouseEvent} e - The mouse event containing position information
|
||||
* @returns {void}
|
||||
*/
|
||||
updateProgress(e) {
|
||||
if (!this.elements.progress) return;
|
||||
|
||||
const rect = this.elements.progress.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const percent = Math.max(0, Math.min(100, (offsetX / width) * 100));
|
||||
|
||||
if (this.elements.progressBar) {
|
||||
/** @type {HTMLElement} */ (this.elements.progressBar).style.width = percent + '%';
|
||||
}
|
||||
|
||||
const seekTime = (percent / 100) * this.audio.duration;
|
||||
if (isFinite(seekTime)) {
|
||||
this.audio.currentTime = seekTime;
|
||||
if (this.elements.currentTime) {
|
||||
this.elements.currentTime.textContent = formatTime(seekTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the volume icon based on current volume and mute state
|
||||
* @returns {void}
|
||||
*/
|
||||
updateVolumeIcon() {
|
||||
if (!this.elements.volumeBtn) return;
|
||||
|
||||
const volume = this.audio.volume;
|
||||
const isMuted = this.audio.muted;
|
||||
|
||||
this.elements.volumeBtn.classList.remove('fa-volume-high', 'fa-volume-low', 'fa-volume-off', 'fa-volume-xmark');
|
||||
|
||||
if (isMuted || volume === 0) {
|
||||
this.elements.volumeBtn.classList.add('fa-volume-xmark');
|
||||
} else if (volume < 0.5) {
|
||||
this.elements.volumeBtn.classList.add('fa-volume-low');
|
||||
} else {
|
||||
this.elements.volumeBtn.classList.add('fa-volume-high');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current time and total time display elements
|
||||
* @returns {void}
|
||||
*/
|
||||
updateTimeDisplays() {
|
||||
if (this.elements.currentTime) {
|
||||
this.elements.currentTime.textContent = formatTime(this.audio.currentTime || 0);
|
||||
}
|
||||
if (this.elements.totalTime) {
|
||||
this.elements.totalTime.textContent = formatTime(this.audio.duration || 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the mouseover title on the progress bar to show time at cursor position
|
||||
* @param {MouseEvent} e - The mouse event
|
||||
* @returns {void}
|
||||
*/
|
||||
updateProgressTitle(e) {
|
||||
if (!this.elements.progress) return;
|
||||
|
||||
const rect = this.elements.progress.getBoundingClientRect();
|
||||
const offsetX = e.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const percent = Math.max(0, Math.min(100, (offsetX / width) * 100));
|
||||
|
||||
this.elements.progress.setAttribute('title', formatTime((percent / 100) * this.audio.duration));
|
||||
}
|
||||
|
||||
// Public methods
|
||||
/**
|
||||
* Starts audio playback
|
||||
* @returns {void}
|
||||
*/
|
||||
play() {
|
||||
if (this.isDestroyed) return;
|
||||
if (this.audio.paused) {
|
||||
const playPromise = this.audio.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(error => {
|
||||
console.error('Audio play failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses audio playback
|
||||
* @returns {void}
|
||||
*/
|
||||
pause() {
|
||||
if (this.isDestroyed) return;
|
||||
if (!this.audio.paused) {
|
||||
this.audio.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles between play and pause states
|
||||
* @returns {void}
|
||||
*/
|
||||
togglePlay() {
|
||||
if (this.audio.paused) {
|
||||
this.play();
|
||||
} else {
|
||||
this.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks to a specific time in the audio
|
||||
* @param {number} time - The time in seconds to seek to
|
||||
* @returns {void}
|
||||
*/
|
||||
seek(time) {
|
||||
if (this.isDestroyed) return;
|
||||
if (isFinite(time) && time >= 0 && time <= this.audio.duration) {
|
||||
this.audio.currentTime = time;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the volume level
|
||||
* @param {number} volume - Volume level between 0.0 and 1.0
|
||||
* @returns {void}
|
||||
*/
|
||||
setVolume(volume) {
|
||||
if (this.isDestroyed) return;
|
||||
volume = Math.max(0, Math.min(1, volume));
|
||||
this.audio.volume = volume;
|
||||
|
||||
if (volume > 0 && this.audio.muted) {
|
||||
this.audio.muted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes the audio
|
||||
* @returns {void}
|
||||
*/
|
||||
mute() {
|
||||
if (this.isDestroyed) return;
|
||||
this.audio.muted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmutes the audio
|
||||
* @returns {void}
|
||||
*/
|
||||
unmute() {
|
||||
if (this.isDestroyed) return;
|
||||
this.audio.muted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the mute state
|
||||
* @returns {void}
|
||||
*/
|
||||
toggleMute() {
|
||||
if (this.isDestroyed) return;
|
||||
this.audio.muted = !this.audio.muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the audio source URL
|
||||
* @param {string} src - The URL of the audio file
|
||||
* @returns {void}
|
||||
*/
|
||||
setSrc(src) {
|
||||
if (this.isDestroyed) return;
|
||||
this.audio.src = src;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the title displayed in the player
|
||||
* @param {string} title - The title text to display
|
||||
* @returns {void}
|
||||
*/
|
||||
setTitle(title) {
|
||||
if (this.isDestroyed) return;
|
||||
this.options.title = title;
|
||||
if (this.elements.title) {
|
||||
this.elements.title.textContent = title;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the player by removing event listeners and clearing references
|
||||
* @returns {void}
|
||||
*/
|
||||
destroy() {
|
||||
if (this.isDestroyed) return;
|
||||
this.isDestroyed = true;
|
||||
|
||||
// Stop observing DOM changes
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
|
||||
// Pause and clear audio
|
||||
this.pause();
|
||||
this.audio.src = '';
|
||||
|
||||
// Remove all event listeners
|
||||
this.unbindEvents();
|
||||
|
||||
// Clear references to prevent memory leaks
|
||||
this.audio = null;
|
||||
this.container = null;
|
||||
this.elements = null;
|
||||
this.options = null;
|
||||
this.boundHandlers = null;
|
||||
}
|
||||
}
|
||||
621
web-app/public/scripts/authors-note.js
Normal file
621
web-app/public/scripts/authors-note.js
Normal file
@@ -0,0 +1,621 @@
|
||||
import {
|
||||
MAX_INJECTION_DEPTH,
|
||||
animation_duration,
|
||||
chat_metadata,
|
||||
eventSource,
|
||||
event_types,
|
||||
extension_prompt_roles,
|
||||
extension_prompt_types,
|
||||
saveSettingsDebounced,
|
||||
this_chid,
|
||||
} from '../script.js';
|
||||
import { selected_group } from './group-chats.js';
|
||||
import { extension_settings, getContext, saveMetadataDebounced } from './extensions.js';
|
||||
import { getCharaFilename, debounce, delay } from './utils.js';
|
||||
import { getTokenCountAsync } from './tokenizers.js';
|
||||
import { debounce_timeout } from './constants.js';
|
||||
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
|
||||
import { SlashCommand } from './slash-commands/SlashCommand.js';
|
||||
import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
|
||||
export { MODULE_NAME as NOTE_MODULE_NAME };
|
||||
import { t } from './i18n.js';
|
||||
import { macros, MacroCategory } from './macros/macro-system.js';
|
||||
import { MacrosParser } from './macros.js';
|
||||
import { power_user } from './power-user.js';
|
||||
|
||||
const MODULE_NAME = '2_floating_prompt'; // <= Deliberate, for sorting lower than memory
|
||||
|
||||
export var shouldWIAddPrompt = false;
|
||||
|
||||
export const metadata_keys = {
|
||||
prompt: 'note_prompt',
|
||||
interval: 'note_interval',
|
||||
depth: 'note_depth',
|
||||
position: 'note_position',
|
||||
role: 'note_role',
|
||||
};
|
||||
|
||||
const chara_note_position = {
|
||||
replace: 0,
|
||||
before: 1,
|
||||
after: 2,
|
||||
};
|
||||
|
||||
function setNoteTextCommand(_, text) {
|
||||
if (text) {
|
||||
$('#extension_floating_prompt').val(text).trigger('input');
|
||||
toastr.success(t`Author's Note text updated`);
|
||||
}
|
||||
return chat_metadata[metadata_keys.prompt];
|
||||
}
|
||||
|
||||
function setNoteDepthCommand(_, text) {
|
||||
if (text) {
|
||||
const value = Number(text);
|
||||
|
||||
if (Number.isNaN(value)) {
|
||||
toastr.error(t`Not a valid number`);
|
||||
return;
|
||||
}
|
||||
|
||||
$('#extension_floating_depth').val(Math.abs(value)).trigger('input');
|
||||
toastr.success(t`Author's Note depth updated`);
|
||||
}
|
||||
return chat_metadata[metadata_keys.depth];
|
||||
}
|
||||
|
||||
function setNoteIntervalCommand(_, text) {
|
||||
if (text) {
|
||||
const value = Number(text);
|
||||
|
||||
if (Number.isNaN(value)) {
|
||||
toastr.error(t`Not a valid number`);
|
||||
return;
|
||||
}
|
||||
|
||||
$('#extension_floating_interval').val(Math.abs(value)).trigger('input');
|
||||
toastr.success(t`Author's Note frequency updated`);
|
||||
}
|
||||
return chat_metadata[metadata_keys.interval];
|
||||
}
|
||||
|
||||
function setNotePositionCommand(_, text) {
|
||||
const validPositions = {
|
||||
'after': 0,
|
||||
'scenario': 0,
|
||||
'chat': 1,
|
||||
'before_scenario': 2,
|
||||
'before': 2,
|
||||
};
|
||||
|
||||
if (text) {
|
||||
const position = validPositions[text?.trim()?.toLowerCase()];
|
||||
|
||||
if (typeof position === 'undefined') {
|
||||
toastr.error(t`Not a valid position`);
|
||||
return;
|
||||
}
|
||||
|
||||
$(`input[name="extension_floating_position"][value="${position}"]`).prop('checked', true).trigger('input');
|
||||
toastr.info(t`Author's Note position updated`);
|
||||
}
|
||||
return Object.keys(validPositions).find(key => validPositions[key] == chat_metadata[metadata_keys.position]);
|
||||
}
|
||||
|
||||
function setNoteRoleCommand(_, text) {
|
||||
const validRoles = {
|
||||
'system': 0,
|
||||
'user': 1,
|
||||
'assistant': 2,
|
||||
};
|
||||
|
||||
if (text) {
|
||||
const role = validRoles[text?.trim()?.toLowerCase()];
|
||||
|
||||
if (typeof role === 'undefined') {
|
||||
toastr.error(t`Not a valid role`);
|
||||
return;
|
||||
}
|
||||
|
||||
$('#extension_floating_role').val(Math.abs(role)).trigger('input');
|
||||
toastr.info(t`Author's Note role updated`);
|
||||
}
|
||||
return Object.keys(validRoles).find(key => validRoles[key] == chat_metadata[metadata_keys.role]);
|
||||
}
|
||||
|
||||
function updateSettings() {
|
||||
saveSettingsDebounced();
|
||||
loadSettings();
|
||||
setFloatingPrompt();
|
||||
}
|
||||
|
||||
const setMainPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_prompt_token_counter').text(await getTokenCountAsync(value)), debounce_timeout.relaxed);
|
||||
const setCharaPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_chara_token_counter').text(await getTokenCountAsync(value)), debounce_timeout.relaxed);
|
||||
const setDefaultPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_default_token_counter').text(await getTokenCountAsync(value)), debounce_timeout.relaxed);
|
||||
|
||||
async function onExtensionFloatingPromptInput() {
|
||||
chat_metadata[metadata_keys.prompt] = $(this).val();
|
||||
setMainPromptTokenCounterDebounced(chat_metadata[metadata_keys.prompt]);
|
||||
updateSettings();
|
||||
saveMetadataDebounced();
|
||||
}
|
||||
|
||||
async function onExtensionFloatingIntervalInput() {
|
||||
chat_metadata[metadata_keys.interval] = Number($(this).val());
|
||||
updateSettings();
|
||||
saveMetadataDebounced();
|
||||
}
|
||||
|
||||
async function onExtensionFloatingDepthInput() {
|
||||
let value = Number($(this).val());
|
||||
|
||||
if (value < 0) {
|
||||
value = Math.abs(value);
|
||||
$(this).val(value);
|
||||
}
|
||||
|
||||
chat_metadata[metadata_keys.depth] = value;
|
||||
updateSettings();
|
||||
saveMetadataDebounced();
|
||||
}
|
||||
|
||||
async function onExtensionFloatingPositionInput(e) {
|
||||
chat_metadata[metadata_keys.position] = Number(e.target.value);
|
||||
updateSettings();
|
||||
saveMetadataDebounced();
|
||||
}
|
||||
|
||||
async function onDefaultPositionInput(e) {
|
||||
extension_settings.note.defaultPosition = Number(e.target.value);
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
async function onDefaultDepthInput() {
|
||||
let value = Number($(this).val());
|
||||
|
||||
if (value < 0) {
|
||||
value = Math.abs(value);
|
||||
$(this).val(value);
|
||||
}
|
||||
|
||||
extension_settings.note.defaultDepth = value;
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
async function onDefaultIntervalInput() {
|
||||
extension_settings.note.defaultInterval = Number($(this).val());
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
function onExtensionFloatingRoleInput(e) {
|
||||
chat_metadata[metadata_keys.role] = Number(e.target.value);
|
||||
updateSettings();
|
||||
}
|
||||
|
||||
function onExtensionDefaultRoleInput(e) {
|
||||
extension_settings.note.defaultRole = Number(e.target.value);
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
async function onExtensionFloatingCharPositionInput(e) {
|
||||
const value = e.target.value;
|
||||
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
|
||||
|
||||
if (charaNote) {
|
||||
charaNote.position = Number(value);
|
||||
updateSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function onExtensionFloatingCharaPromptInput() {
|
||||
const tempPrompt = $(this).val();
|
||||
const avatarName = getCharaFilename();
|
||||
let tempCharaNote = {
|
||||
name: avatarName,
|
||||
prompt: tempPrompt,
|
||||
};
|
||||
|
||||
setCharaPromptTokenCounterDebounced(tempPrompt);
|
||||
|
||||
let existingCharaNoteIndex;
|
||||
let existingCharaNote;
|
||||
|
||||
if (extension_settings.note.chara) {
|
||||
existingCharaNoteIndex = extension_settings.note.chara.findIndex((e) => e.name === avatarName);
|
||||
existingCharaNote = extension_settings.note.chara[existingCharaNoteIndex];
|
||||
}
|
||||
|
||||
if (tempPrompt.length === 0 &&
|
||||
extension_settings.note.chara &&
|
||||
existingCharaNote &&
|
||||
!existingCharaNote.useChara
|
||||
) {
|
||||
extension_settings.note.chara.splice(existingCharaNoteIndex, 1);
|
||||
}
|
||||
else if (extension_settings.note.chara && existingCharaNote) {
|
||||
Object.assign(existingCharaNote, tempCharaNote);
|
||||
}
|
||||
else if (avatarName && tempPrompt.length > 0) {
|
||||
if (!extension_settings.note.chara) {
|
||||
extension_settings.note.chara = [];
|
||||
}
|
||||
Object.assign(tempCharaNote, { useChara: false, position: chara_note_position.replace });
|
||||
|
||||
extension_settings.note.chara.push(tempCharaNote);
|
||||
} else {
|
||||
console.log('Character author\'s note error: No avatar name key could be found.');
|
||||
toastr.error(t`Something went wrong. Could not save character's author's note.`);
|
||||
|
||||
// Don't save settings if something went wrong
|
||||
return;
|
||||
}
|
||||
|
||||
updateSettings();
|
||||
}
|
||||
|
||||
function onExtensionFloatingCharaCheckboxChanged() {
|
||||
const value = !!$(this).prop('checked');
|
||||
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
|
||||
|
||||
if (charaNote) {
|
||||
charaNote.useChara = value;
|
||||
|
||||
updateSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function onExtensionFloatingDefaultInput() {
|
||||
extension_settings.note.default = $(this).val();
|
||||
setDefaultPromptTokenCounterDebounced(extension_settings.note.default);
|
||||
updateSettings();
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const DEFAULT_DEPTH = 4;
|
||||
const DEFAULT_POSITION = 1;
|
||||
const DEFAULT_INTERVAL = 1;
|
||||
const DEFAULT_ROLE = extension_prompt_roles.SYSTEM;
|
||||
|
||||
if (extension_settings.note.defaultPosition === undefined) {
|
||||
extension_settings.note.defaultPosition = DEFAULT_POSITION;
|
||||
}
|
||||
|
||||
if (extension_settings.note.defaultDepth === undefined) {
|
||||
extension_settings.note.defaultDepth = DEFAULT_DEPTH;
|
||||
}
|
||||
|
||||
if (extension_settings.note.defaultInterval === undefined) {
|
||||
extension_settings.note.defaultInterval = DEFAULT_INTERVAL;
|
||||
}
|
||||
|
||||
if (extension_settings.note.defaultRole === undefined) {
|
||||
extension_settings.note.defaultRole = DEFAULT_ROLE;
|
||||
}
|
||||
|
||||
chat_metadata[metadata_keys.prompt] = chat_metadata[metadata_keys.prompt] ?? extension_settings.note.default ?? '';
|
||||
chat_metadata[metadata_keys.interval] = chat_metadata[metadata_keys.interval] ?? extension_settings.note.defaultInterval ?? DEFAULT_INTERVAL;
|
||||
chat_metadata[metadata_keys.position] = chat_metadata[metadata_keys.position] ?? extension_settings.note.defaultPosition ?? DEFAULT_POSITION;
|
||||
chat_metadata[metadata_keys.depth] = chat_metadata[metadata_keys.depth] ?? extension_settings.note.defaultDepth ?? DEFAULT_DEPTH;
|
||||
chat_metadata[metadata_keys.role] = chat_metadata[metadata_keys.role] ?? extension_settings.note.defaultRole ?? DEFAULT_ROLE;
|
||||
$('#extension_floating_prompt').val(chat_metadata[metadata_keys.prompt]);
|
||||
$('#extension_floating_interval').val(chat_metadata[metadata_keys.interval]);
|
||||
$('#extension_floating_allow_wi_scan').prop('checked', extension_settings.note.allowWIScan ?? false);
|
||||
$('#extension_floating_depth').val(chat_metadata[metadata_keys.depth]);
|
||||
$('#extension_floating_role').val(chat_metadata[metadata_keys.role]);
|
||||
$(`input[name="extension_floating_position"][value="${chat_metadata[metadata_keys.position]}"]`).prop('checked', true);
|
||||
|
||||
if (extension_settings.note.chara && getContext().characterId !== undefined) {
|
||||
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
|
||||
|
||||
$('#extension_floating_chara').val(charaNote ? charaNote.prompt : '');
|
||||
$('#extension_use_floating_chara').prop('checked', charaNote ? charaNote.useChara : false);
|
||||
$(`input[name="extension_floating_char_position"][value="${charaNote?.position ?? chara_note_position.replace}"]`).prop('checked', true);
|
||||
} else {
|
||||
$('#extension_floating_chara').val('');
|
||||
$('#extension_use_floating_chara').prop('checked', false);
|
||||
$(`input[name="extension_floating_char_position"][value="${chara_note_position.replace}"]`).prop('checked', true);
|
||||
}
|
||||
|
||||
$('#extension_floating_default').val(extension_settings.note.default);
|
||||
$('#extension_default_depth').val(extension_settings.note.defaultDepth);
|
||||
$('#extension_default_interval').val(extension_settings.note.defaultInterval);
|
||||
$('#extension_default_role').val(extension_settings.note.defaultRole);
|
||||
$(`input[name="extension_default_position"][value="${extension_settings.note.defaultPosition}"]`).prop('checked', true);
|
||||
}
|
||||
|
||||
export function setFloatingPrompt() {
|
||||
const context = getContext();
|
||||
if (!context.groupId && context.characterId === undefined) {
|
||||
console.debug('setFloatingPrompt: Not in a chat. Skipping.');
|
||||
shouldWIAddPrompt = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// take the count of messages
|
||||
let lastMessageNumber = Array.isArray(context.chat) && context.chat.length ? context.chat.filter(m => m.is_user).length : 0;
|
||||
|
||||
console.debug(`
|
||||
setFloatingPrompt entered
|
||||
------
|
||||
lastMessageNumber = ${lastMessageNumber}
|
||||
metadata_keys.interval = ${chat_metadata[metadata_keys.interval]}
|
||||
metadata_keys.position = ${chat_metadata[metadata_keys.position]}
|
||||
metadata_keys.depth = ${chat_metadata[metadata_keys.depth]}
|
||||
metadata_keys.role = ${chat_metadata[metadata_keys.role]}
|
||||
------
|
||||
`);
|
||||
|
||||
// interval 1 should be inserted no matter what
|
||||
if (chat_metadata[metadata_keys.interval] === 1) {
|
||||
lastMessageNumber = 1;
|
||||
}
|
||||
|
||||
if (lastMessageNumber <= 0 || chat_metadata[metadata_keys.interval] <= 0) {
|
||||
context.setExtensionPrompt(MODULE_NAME, '', extension_prompt_types.NONE, MAX_INJECTION_DEPTH);
|
||||
$('#extension_floating_counter').text('(disabled)');
|
||||
shouldWIAddPrompt = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesTillInsertion = lastMessageNumber >= chat_metadata[metadata_keys.interval]
|
||||
? (lastMessageNumber % chat_metadata[metadata_keys.interval])
|
||||
: (chat_metadata[metadata_keys.interval] - lastMessageNumber);
|
||||
const shouldAddPrompt = messagesTillInsertion == 0;
|
||||
shouldWIAddPrompt = shouldAddPrompt;
|
||||
|
||||
let prompt = shouldAddPrompt ? $('#extension_floating_prompt').val() : '';
|
||||
if (shouldAddPrompt && extension_settings.note.chara && getContext().characterId !== undefined) {
|
||||
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
|
||||
|
||||
// Only replace with the chara note if the user checked the box
|
||||
if (charaNote && charaNote.useChara) {
|
||||
switch (charaNote.position) {
|
||||
case chara_note_position.before:
|
||||
prompt = charaNote.prompt + '\n' + prompt;
|
||||
break;
|
||||
case chara_note_position.after:
|
||||
prompt = prompt + '\n' + charaNote.prompt;
|
||||
break;
|
||||
default:
|
||||
prompt = charaNote.prompt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
context.setExtensionPrompt(
|
||||
MODULE_NAME,
|
||||
String(prompt),
|
||||
chat_metadata[metadata_keys.position],
|
||||
chat_metadata[metadata_keys.depth],
|
||||
extension_settings.note.allowWIScan,
|
||||
chat_metadata[metadata_keys.role],
|
||||
);
|
||||
$('#extension_floating_counter').text(shouldAddPrompt ? '0' : messagesTillInsertion);
|
||||
}
|
||||
|
||||
function onANMenuItemClick() {
|
||||
if (!selected_group && this_chid === undefined) {
|
||||
toastr.warning(t`Select a character before trying to use Author's Note`, '', { timeOut: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
//show AN if it's hidden
|
||||
const $ANcontainer = $('#floatingPrompt');
|
||||
if ($ANcontainer.css('display') !== 'flex') {
|
||||
$ANcontainer.addClass('resizing');
|
||||
$ANcontainer.css('display', 'flex');
|
||||
$ANcontainer.css('opacity', 0.0);
|
||||
$ANcontainer.transition({
|
||||
opacity: 1.0,
|
||||
duration: animation_duration,
|
||||
}, async function () {
|
||||
await delay(50);
|
||||
$ANcontainer.removeClass('resizing');
|
||||
});
|
||||
|
||||
//auto-open the main AN inline drawer
|
||||
if ($('#ANBlockToggle')
|
||||
.siblings('.inline-drawer-content')
|
||||
.css('display') !== 'block') {
|
||||
$ANcontainer.addClass('resizing');
|
||||
$('#ANBlockToggle').trigger('click');
|
||||
}
|
||||
} else {
|
||||
//hide AN if it's already displayed
|
||||
$ANcontainer.addClass('resizing');
|
||||
$ANcontainer.transition({
|
||||
opacity: 0.0,
|
||||
duration: animation_duration,
|
||||
}, async function () {
|
||||
await delay(50);
|
||||
$ANcontainer.removeClass('resizing');
|
||||
});
|
||||
setTimeout(function () {
|
||||
$ANcontainer.hide();
|
||||
}, animation_duration);
|
||||
}
|
||||
|
||||
//duplicate options menu close handler from script.js
|
||||
//because this listener takes priority
|
||||
$('#options').stop().fadeOut(animation_duration);
|
||||
}
|
||||
|
||||
async function onChatChanged() {
|
||||
loadSettings();
|
||||
setFloatingPrompt();
|
||||
const context = getContext();
|
||||
|
||||
// Disable the chara note if in a group
|
||||
$('#extension_floating_chara').prop('disabled', !!context.groupId);
|
||||
|
||||
const tokenCounter1 = chat_metadata[metadata_keys.prompt] ? await getTokenCountAsync(chat_metadata[metadata_keys.prompt]) : 0;
|
||||
$('#extension_floating_prompt_token_counter').text(tokenCounter1);
|
||||
|
||||
let tokenCounter2;
|
||||
if (extension_settings.note.chara && context.characterId !== undefined) {
|
||||
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
|
||||
|
||||
if (charaNote) {
|
||||
tokenCounter2 = await getTokenCountAsync(charaNote.prompt);
|
||||
}
|
||||
}
|
||||
|
||||
$('#extension_floating_chara_token_counter').text(tokenCounter2 || 0);
|
||||
|
||||
const tokenCounter3 = extension_settings.note.default ? await getTokenCountAsync(extension_settings.note.default) : 0;
|
||||
$('#extension_floating_default_token_counter').text(tokenCounter3);
|
||||
}
|
||||
|
||||
function onAllowWIScanCheckboxChanged() {
|
||||
extension_settings.note.allowWIScan = !!$(this).prop('checked');
|
||||
updateSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject author's note options and setup event listeners.
|
||||
*/
|
||||
// Inserts the extension first since it's statically imported
|
||||
export function initAuthorsNote() {
|
||||
$('#extension_floating_prompt').on('input', onExtensionFloatingPromptInput);
|
||||
$('#extension_floating_interval').on('input', onExtensionFloatingIntervalInput);
|
||||
$('#extension_floating_depth').on('input', onExtensionFloatingDepthInput);
|
||||
$('#extension_floating_chara').on('input', onExtensionFloatingCharaPromptInput);
|
||||
$('#extension_use_floating_chara').on('input', onExtensionFloatingCharaCheckboxChanged);
|
||||
$('#extension_floating_default').on('input', onExtensionFloatingDefaultInput);
|
||||
$('#extension_default_depth').on('input', onDefaultDepthInput);
|
||||
$('#extension_default_interval').on('input', onDefaultIntervalInput);
|
||||
$('#extension_floating_allow_wi_scan').on('input', onAllowWIScanCheckboxChanged);
|
||||
$('#extension_floating_role').on('input', onExtensionFloatingRoleInput);
|
||||
$('#extension_default_role').on('input', onExtensionDefaultRoleInput);
|
||||
$('input[name="extension_floating_position"]').on('change', onExtensionFloatingPositionInput);
|
||||
$('input[name="extension_default_position"]').on('change', onDefaultPositionInput);
|
||||
$('input[name="extension_floating_char_position"]').on('change', onExtensionFloatingCharPositionInput);
|
||||
$('#ANClose').on('click', function () {
|
||||
$('#floatingPrompt').transition({
|
||||
opacity: 0,
|
||||
duration: animation_duration,
|
||||
easing: 'ease-in-out',
|
||||
});
|
||||
setTimeout(function () { $('#floatingPrompt').hide(); }, animation_duration);
|
||||
});
|
||||
$('#option_toggle_AN').on('click', onANMenuItemClick);
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'note',
|
||||
callback: setNoteTextCommand,
|
||||
returns: 'current author\'s note',
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument(
|
||||
'text', [ARGUMENT_TYPE.STRING], false,
|
||||
),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Sets an author's note for the currently selected chat if specified and returns the current note.
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'note-depth',
|
||||
aliases: ['depth'],
|
||||
callback: setNoteDepthCommand,
|
||||
returns: 'current author\'s note depth',
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument(
|
||||
'number', [ARGUMENT_TYPE.NUMBER], false,
|
||||
),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Sets an author's note depth for in-chat positioning if specified and returns the current depth.
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'note-frequency',
|
||||
aliases: ['freq', 'note-freq'],
|
||||
callback: setNoteIntervalCommand,
|
||||
returns: 'current author\'s note insertion frequency',
|
||||
namedArgumentList: [],
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument(
|
||||
'number', [ARGUMENT_TYPE.NUMBER], false,
|
||||
),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Sets an author's note insertion frequency if specified and returns the current frequency.
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'note-position',
|
||||
callback: setNotePositionCommand,
|
||||
aliases: ['pos', 'note-pos'],
|
||||
returns: 'current author\'s note insertion position',
|
||||
namedArgumentList: [],
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument(
|
||||
'position', [ARGUMENT_TYPE.STRING], false, false, null, ['before', 'after', 'chat'],
|
||||
),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Sets an author's note position if specified and returns the current position.
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'note-role',
|
||||
callback: setNoteRoleCommand,
|
||||
returns: 'current author\'s note chat insertion role',
|
||||
namedArgumentList: [],
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument(
|
||||
'role', [ARGUMENT_TYPE.STRING], false, false, null, ['system', 'user', 'assistant'],
|
||||
),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Sets an author's note chat insertion role if specified and returns the current role.
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
|
||||
|
||||
registerAuthorsNoteMacros();
|
||||
}
|
||||
|
||||
function registerAuthorsNoteMacros() {
|
||||
if (power_user.experimental_macro_engine) {
|
||||
macros.register('authorsNote', {
|
||||
category: MacroCategory.PROMPTS,
|
||||
description: t`The contents of the Author's Note`,
|
||||
handler: () => chat_metadata[metadata_keys.prompt] ?? '',
|
||||
});
|
||||
macros.register('charAuthorsNote', {
|
||||
category: MacroCategory.CHARACTER,
|
||||
description: t`The contents of the Character Author's Note`,
|
||||
handler: () => this_chid !== undefined ? (extension_settings.note.chara.find((e) => e.name === getCharaFilename())?.prompt ?? '') : '',
|
||||
});
|
||||
macros.register('defaultAuthorsNote', {
|
||||
category: MacroCategory.PROMPTS,
|
||||
description: t`The contents of the Default Author's Note`,
|
||||
handler: () => extension_settings.note.default ?? '',
|
||||
});
|
||||
} else {
|
||||
// TODO: Remove this when the experimental macro engine is replacing the old macro engine
|
||||
MacrosParser.registerMacro('authorsNote',
|
||||
() => chat_metadata[metadata_keys.prompt] ?? '',
|
||||
t`The contents of the Author's Note`,
|
||||
);
|
||||
MacrosParser.registerMacro('charAuthorsNote',
|
||||
() => this_chid !== undefined ? (extension_settings.note.chara.find((e) => e.name === getCharaFilename())?.prompt ?? '') : '',
|
||||
t`The contents of the Character Author's Note`,
|
||||
);
|
||||
MacrosParser.registerMacro('defaultAuthorsNote',
|
||||
() => extension_settings.note.default ?? '',
|
||||
t`The contents of the Default Author's Note`,
|
||||
);
|
||||
}
|
||||
}
|
||||
828
web-app/public/scripts/autocomplete/AutoComplete.js
Normal file
828
web-app/public/scripts/autocomplete/AutoComplete.js
Normal file
@@ -0,0 +1,828 @@
|
||||
import { power_user } from '../power-user.js';
|
||||
import { debounce, escapeRegex } from '../utils.js';
|
||||
import { AutoCompleteOption } from './AutoCompleteOption.js';
|
||||
import { AutoCompleteFuzzyScore } from './AutoCompleteFuzzyScore.js';
|
||||
import { BlankAutoCompleteOption } from './BlankAutoCompleteOption.js';
|
||||
import { AutoCompleteNameResult } from './AutoCompleteNameResult.js';
|
||||
import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js';
|
||||
|
||||
/**@readonly*/
|
||||
/**@enum {Number}*/
|
||||
export const AUTOCOMPLETE_WIDTH = {
|
||||
'INPUT': 0,
|
||||
'CHAT': 1,
|
||||
'FULL': 2,
|
||||
};
|
||||
|
||||
/**@readonly*/
|
||||
/**@enum {Number}*/
|
||||
export const AUTOCOMPLETE_SELECT_KEY = {
|
||||
'TAB': 1, // 2^0
|
||||
'ENTER': 2, // 2^1
|
||||
};
|
||||
|
||||
/** @readonly */
|
||||
/** @enum {Number} */
|
||||
export const AUTOCOMPLETE_STATE = {
|
||||
DISABLED: 0,
|
||||
MIN_LENGTH: 1,
|
||||
ALWAYS: 2,
|
||||
};
|
||||
|
||||
export class AutoComplete {
|
||||
/**@type {HTMLTextAreaElement|HTMLInputElement}*/ textarea;
|
||||
/**@type {boolean}*/ isFloating = false;
|
||||
/**@type {()=>boolean}*/ checkIfActivate;
|
||||
/**@type {(text:string, index:number) => Promise<AutoCompleteNameResult>}*/ getNameAt;
|
||||
|
||||
/**@type {boolean}*/ isActive = false;
|
||||
/**@type {boolean}*/ isReplaceable = false;
|
||||
/**@type {boolean}*/ isShowingDetails = false;
|
||||
/**@type {boolean}*/ wasForced = false;
|
||||
/**@type {boolean}*/ isForceHidden = false;
|
||||
/**@type {boolean}*/ canBeAutoHidden = false;
|
||||
|
||||
/**@type {string}*/ text;
|
||||
/**@type {AutoCompleteNameResult}*/ parserResult;
|
||||
/**@type {AutoCompleteSecondaryNameResult}*/ secondaryParserResult;
|
||||
get effectiveParserResult() { return this.secondaryParserResult ?? this.parserResult; }
|
||||
/**@type {string}*/ name;
|
||||
|
||||
/**@type {boolean}*/ startQuote;
|
||||
/**@type {boolean}*/ endQuote;
|
||||
/**@type {number}*/ selectionStart;
|
||||
|
||||
/**@type {RegExp}*/ fuzzyRegex;
|
||||
|
||||
/**@type {AutoCompleteOption[]}*/ result = [];
|
||||
/**@type {AutoCompleteOption}*/ selectedItem = null;
|
||||
|
||||
/**@type {HTMLElement}*/ clone;
|
||||
/**@type {HTMLElement}*/ domWrap;
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ detailsWrap;
|
||||
/**@type {HTMLElement}*/ detailsDom;
|
||||
|
||||
/**@type {function}*/ renderDebounced;
|
||||
/**@type {function}*/ renderDetailsDebounced;
|
||||
/**@type {function}*/ updatePositionDebounced;
|
||||
/**@type {function}*/ updateDetailsPositionDebounced;
|
||||
/**@type {function}*/ updateFloatingPositionDebounced;
|
||||
|
||||
/**@type {(item:AutoCompleteOption)=>any}*/ onSelect;
|
||||
|
||||
get matchType() {
|
||||
return power_user.stscript.matching ?? 'fuzzy';
|
||||
}
|
||||
|
||||
get autoHide() {
|
||||
return power_user.stscript.autocomplete.autoHide ?? false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {HTMLTextAreaElement|HTMLInputElement} textarea The textarea to receive autocomplete.
|
||||
* @param {() => boolean} checkIfActivate Function should return true only if under the current conditions, autocomplete should display (e.g., for slash commands: autoComplete.text[0] == '/')
|
||||
* @param {(text: string, index: number) => Promise<AutoCompleteNameResult>} getNameAt Function should return (unfiltered, matching against input is done in AutoComplete) information about name options at index in text.
|
||||
* @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor.
|
||||
*/
|
||||
constructor(textarea, checkIfActivate, getNameAt, isFloating = false) {
|
||||
this.textarea = textarea;
|
||||
this.checkIfActivate = checkIfActivate;
|
||||
this.getNameAt = getNameAt;
|
||||
this.isFloating = isFloating;
|
||||
|
||||
this.domWrap = document.createElement('div'); {
|
||||
this.domWrap.classList.add('autoComplete-wrap');
|
||||
if (isFloating) this.domWrap.classList.add('isFloating');
|
||||
}
|
||||
this.dom = document.createElement('ul'); {
|
||||
this.dom.classList.add('autoComplete');
|
||||
this.domWrap.append(this.dom);
|
||||
}
|
||||
this.detailsWrap = document.createElement('div'); {
|
||||
this.detailsWrap.classList.add('autoComplete-detailsWrap');
|
||||
if (isFloating) this.detailsWrap.classList.add('isFloating');
|
||||
}
|
||||
this.detailsDom = document.createElement('div'); {
|
||||
this.detailsDom.classList.add('autoComplete-details');
|
||||
this.detailsWrap.append(this.detailsDom);
|
||||
}
|
||||
|
||||
this.renderDebounced = debounce(this.render.bind(this), 10);
|
||||
this.renderDetailsDebounced = debounce(this.renderDetails.bind(this), 10);
|
||||
this.updatePositionDebounced = debounce(this.updatePosition.bind(this), 10);
|
||||
this.updateDetailsPositionDebounced = debounce(this.updateDetailsPosition.bind(this), 10);
|
||||
this.updateFloatingPositionDebounced = debounce(this.updateFloatingPosition.bind(this), 10);
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
this.selectionStart = this.textarea.selectionStart;
|
||||
if (this.text != this.textarea.value) this.show(true, this.wasForced);
|
||||
});
|
||||
textarea.addEventListener('keydown', (evt) => this.handleKeyDown(evt));
|
||||
textarea.addEventListener('click', () => {
|
||||
this.selectionStart = this.textarea.selectionStart;
|
||||
if (this.isActive) this.show();
|
||||
});
|
||||
textarea.addEventListener('blur', () => this.hide());
|
||||
if (isFloating) {
|
||||
textarea.addEventListener('scroll', () => this.updateFloatingPositionDebounced());
|
||||
}
|
||||
window.addEventListener('resize', () => this.updatePositionDebounced());
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AutoCompleteOption} option
|
||||
*/
|
||||
makeItem(option) {
|
||||
const li = option.renderItem();
|
||||
// gotta listen to pointerdown (happens before textarea-blur)
|
||||
li.addEventListener('pointerdown', (evt) => {
|
||||
evt.preventDefault();
|
||||
this.selectedItem = this.result.find(it => it.name == li.getAttribute('data-name'));
|
||||
this.select();
|
||||
});
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AutoCompleteOption} item
|
||||
*/
|
||||
updateName(item) {
|
||||
const chars = Array.from(item.dom.querySelector('.name').children);
|
||||
switch (this.matchType) {
|
||||
case 'strict': {
|
||||
chars.forEach((it, idx) => {
|
||||
if (idx + item.nameOffset < item.name.length) {
|
||||
it.classList.add('matched');
|
||||
} else {
|
||||
it.classList.remove('matched');
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'includes': {
|
||||
const start = item.name.toLowerCase().search(this.name);
|
||||
chars.forEach((it, idx) => {
|
||||
if (idx + item.nameOffset < start) {
|
||||
it.classList.remove('matched');
|
||||
} else if (idx + item.nameOffset < start + item.name.length) {
|
||||
it.classList.add('matched');
|
||||
} else {
|
||||
it.classList.remove('matched');
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'fuzzy': {
|
||||
item.name.replace(this.fuzzyRegex, (_, ...parts) => {
|
||||
parts.splice(-2, 2);
|
||||
if (parts.length == 2) {
|
||||
chars.forEach(c => c.classList.remove('matched'));
|
||||
} else {
|
||||
let cIdx = item.nameOffset;
|
||||
parts.forEach((it, idx) => {
|
||||
if (it === null || it.length == 0) return '';
|
||||
if (idx % 2 == 1) {
|
||||
chars.slice(cIdx, cIdx + it.length).forEach(c => c.classList.add('matched'));
|
||||
} else {
|
||||
chars.slice(cIdx, cIdx + it.length).forEach(c => c.classList.remove('matched'));
|
||||
}
|
||||
cIdx += it.length;
|
||||
});
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate score for the fuzzy match.
|
||||
* @param {AutoCompleteOption} option
|
||||
* @returns The option.
|
||||
*/
|
||||
fuzzyScore(option) {
|
||||
// might have been matched by the options matchProvider function instead
|
||||
if (!this.fuzzyRegex.test(option.name)) {
|
||||
option.score = new AutoCompleteFuzzyScore(Number.MAX_SAFE_INTEGER, -1);
|
||||
return option;
|
||||
}
|
||||
const parts = this.fuzzyRegex.exec(option.name).slice(1, -1);
|
||||
let start = null;
|
||||
let consecutive = [];
|
||||
let current = '';
|
||||
let offset = 0;
|
||||
parts.forEach((part, idx) => {
|
||||
if (idx % 2 == 0) {
|
||||
if (part.length > 0) {
|
||||
if (current.length > 0) {
|
||||
consecutive.push(current);
|
||||
}
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
if (start === null) {
|
||||
start = offset;
|
||||
}
|
||||
current += part;
|
||||
}
|
||||
offset += part.length;
|
||||
});
|
||||
if (current.length > 0) {
|
||||
consecutive.push(current);
|
||||
}
|
||||
consecutive.sort((a, b) => b.length - a.length);
|
||||
option.score = new AutoCompleteFuzzyScore(start, consecutive[0]?.length ?? 0);
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two auto complete options by their fuzzy score.
|
||||
* @param {AutoCompleteOption} a
|
||||
* @param {AutoCompleteOption} b
|
||||
*/
|
||||
fuzzyScoreCompare(a, b) {
|
||||
if (a.score.start < b.score.start) return -1;
|
||||
if (a.score.start > b.score.start) return 1;
|
||||
if (a.score.longestConsecutive > b.score.longestConsecutive) return -1;
|
||||
if (a.score.longestConsecutive < b.score.longestConsecutive) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
basicAutoHideCheck() {
|
||||
// auto hide only if at least one char has been typed after the name + space
|
||||
return this.textarea.selectionStart > this.parserResult.start
|
||||
+ this.parserResult.name.length
|
||||
+ (this.startQuote ? 1 : 0)
|
||||
+ (this.endQuote ? 1 : 0)
|
||||
+ 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the autocomplete.
|
||||
* @param {boolean} isInput Whether triggered by input.
|
||||
* @param {boolean} isForced Whether force-showing (ctrl+space).
|
||||
* @param {boolean} isSelect Whether an autocomplete option was just selected.
|
||||
*/
|
||||
async show(isInput = false, isForced = false, isSelect = false) {
|
||||
//TODO check if isInput and isForced are both required
|
||||
this.text = this.textarea.value;
|
||||
this.isReplaceable = false;
|
||||
|
||||
if (document.activeElement != this.textarea) {
|
||||
// only show with textarea in focus
|
||||
return this.hide();
|
||||
}
|
||||
if (!this.checkIfActivate()) {
|
||||
// only show if provider wants to
|
||||
return this.hide();
|
||||
}
|
||||
|
||||
// disable force-hide if trigger was forced
|
||||
if (isForced) this.isForceHidden = false;
|
||||
|
||||
// request provider to get name result (potentially "incomplete", i.e. not an actual existing name) for
|
||||
// cursor position
|
||||
this.parserResult = await this.getNameAt(this.text, this.textarea.selectionStart);
|
||||
this.secondaryParserResult = null;
|
||||
|
||||
if (!this.parserResult) {
|
||||
// don't show if no name result found, e.g., cursor's area is not a command
|
||||
return this.hide();
|
||||
}
|
||||
|
||||
// need to know if name can be inside quotes, and then check if quotes are already there
|
||||
if (this.parserResult.canBeQuoted) {
|
||||
this.startQuote = this.text[this.parserResult.start] == '"';
|
||||
this.endQuote = this.startQuote && this.text[this.parserResult.start + this.parserResult.name.length + 1] == '"';
|
||||
} else {
|
||||
this.startQuote = false;
|
||||
this.endQuote = false;
|
||||
}
|
||||
|
||||
// use lowercase name for matching
|
||||
this.name = this.parserResult.name.toLowerCase() ?? '';
|
||||
|
||||
const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0);
|
||||
if (isForced || isInput) {
|
||||
// if forced (ctrl+space) or user input...
|
||||
if (isCursorInNamePart) {
|
||||
// ...and cursor is somewhere in the name part (including right behind the final char)
|
||||
// -> show autocomplete for the (partial if cursor in the middle) name
|
||||
this.name = this.name.slice(0, this.textarea.selectionStart - (this.parserResult.start) - (this.startQuote ? 1 : 0));
|
||||
this.parserResult.name = this.name;
|
||||
this.isReplaceable = true;
|
||||
this.isForceHidden = false;
|
||||
this.canBeAutoHidden = false;
|
||||
} else {
|
||||
this.isReplaceable = false;
|
||||
this.canBeAutoHidden = this.basicAutoHideCheck();
|
||||
}
|
||||
} else {
|
||||
// if not forced and no user input -> just show details
|
||||
this.isReplaceable = false;
|
||||
this.canBeAutoHidden = this.basicAutoHideCheck();
|
||||
}
|
||||
|
||||
if (isForced || isInput || isSelect) {
|
||||
// is forced or user input or just selected autocomplete option...
|
||||
if (!isCursorInNamePart) {
|
||||
// ...and cursor is not somwehere in the main name part -> check for secondary options (e.g., named arguments)
|
||||
const result = this.parserResult.getSecondaryNameAt(this.text, this.textarea.selectionStart, isSelect);
|
||||
if (result && (isForced || result.isRequired)) {
|
||||
this.secondaryParserResult = result;
|
||||
this.name = this.secondaryParserResult.name;
|
||||
this.isReplaceable = isForced || this.secondaryParserResult.isRequired;
|
||||
this.isForceHidden = false;
|
||||
this.canBeAutoHidden = false;
|
||||
} else {
|
||||
this.isReplaceable = false;
|
||||
this.canBeAutoHidden = this.basicAutoHideCheck();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.matchType == 'fuzzy') {
|
||||
// only build the fuzzy regex if match type is set to fuzzy
|
||||
this.fuzzyRegex = new RegExp(`^(.*?)${this.name.split('').map(char => `(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i');
|
||||
}
|
||||
|
||||
//TODO maybe move the matchers somewhere else; a single match function? matchType is available as property
|
||||
const matchers = {
|
||||
'strict': (name) => name.toLowerCase().startsWith(this.name),
|
||||
'includes': (name) => name.toLowerCase().includes(this.name),
|
||||
'fuzzy': (name) => this.fuzzyRegex.test(name),
|
||||
};
|
||||
|
||||
this.result = this.effectiveParserResult.optionList
|
||||
// filter the list of options by the partial name according to the matching type
|
||||
.filter(it => this.isReplaceable || it.name == '' ? (it.matchProvider ? it.matchProvider(this.name) : matchers[this.matchType](it.name)) : it.name.toLowerCase() == this.name)
|
||||
// remove aliases
|
||||
.filter((it, idx, list) => list.findIndex(opt => opt.value == it.value) == idx);
|
||||
|
||||
if (this.result.length == 0 && this.effectiveParserResult != this.parserResult && isForced) {
|
||||
// no matching secondary results and forced trigger -> show current command details
|
||||
this.secondaryParserResult = null;
|
||||
this.result = [this.effectiveParserResult.optionList.find(it => it.name == this.effectiveParserResult.name)];
|
||||
this.name = this.effectiveParserResult.name;
|
||||
this.fuzzyRegex = /(.*)(.*)(.*)/;
|
||||
}
|
||||
|
||||
this.result = this.result
|
||||
// update remaining options
|
||||
.map(option => {
|
||||
// build element
|
||||
option.dom = this.makeItem(option);
|
||||
// update replacer and add quotes if necessary
|
||||
const optionName = option.valueProvider ? option.valueProvider(this.name) : option.name;
|
||||
if (this.effectiveParserResult.canBeQuoted) {
|
||||
option.replacer = optionName.includes(' ') || this.startQuote || this.endQuote ? `"${optionName.replace(/"/g, '\\"')}"` : `${optionName}`;
|
||||
} else {
|
||||
option.replacer = optionName;
|
||||
}
|
||||
// calculate fuzzy score if matching is fuzzy
|
||||
if (this.matchType == 'fuzzy') this.fuzzyScore(option);
|
||||
// update the name to highlight the matched chars
|
||||
this.updateName(option);
|
||||
return option;
|
||||
})
|
||||
// sort by fuzzy score or alphabetical
|
||||
.toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
|
||||
|
||||
if (this.isForceHidden) {
|
||||
// hidden with escape
|
||||
return this.hide();
|
||||
}
|
||||
if (this.autoHide && this.canBeAutoHidden && !isForced && this.effectiveParserResult == this.parserResult && this.result.length == 1) {
|
||||
// auto hide user setting enabled and somewhere after name part and would usually show command details
|
||||
return this.hide();
|
||||
}
|
||||
if (this.result.length == 0) {
|
||||
if (!isInput) {
|
||||
// no result and no input? hide autocomplete
|
||||
return this.hide();
|
||||
}
|
||||
if (this.effectiveParserResult instanceof AutoCompleteSecondaryNameResult && !this.effectiveParserResult.forceMatch) {
|
||||
// no result and matching is no forced? hide autocomplete
|
||||
return this.hide();
|
||||
}
|
||||
// otherwise add "no match" notice
|
||||
const option = new BlankAutoCompleteOption(
|
||||
this.name.length ?
|
||||
this.effectiveParserResult.makeNoMatchText()
|
||||
: this.effectiveParserResult.makeNoOptionsText()
|
||||
,
|
||||
);
|
||||
this.result.push(option);
|
||||
} else if (this.result.length == 1 && this.effectiveParserResult && this.effectiveParserResult != this.secondaryParserResult && this.result[0].name == this.effectiveParserResult.name) {
|
||||
// only one result that is exactly the current value? just show hint, no autocomplete
|
||||
this.isReplaceable = false;
|
||||
this.isShowingDetails = false;
|
||||
} else if (!this.isReplaceable && this.result.length > 1) {
|
||||
return this.hide();
|
||||
}
|
||||
this.selectedItem = this.result[0];
|
||||
this.isActive = true;
|
||||
this.wasForced = isForced;
|
||||
this.renderDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide autocomplete.
|
||||
*/
|
||||
hide() {
|
||||
this.domWrap?.remove();
|
||||
this.detailsWrap?.remove();
|
||||
this.isActive = false;
|
||||
this.isShowingDetails = false;
|
||||
this.wasForced = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Create updated DOM.
|
||||
*/
|
||||
render() {
|
||||
if (!this.isActive) return this.domWrap.remove();
|
||||
if (this.isReplaceable) {
|
||||
this.dom.innerHTML = '';
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const item of this.result) {
|
||||
if (item == this.selectedItem) {
|
||||
item.dom.classList.add('selected');
|
||||
} else {
|
||||
item.dom.classList.remove('selected');
|
||||
}
|
||||
if (!item.isSelectable) {
|
||||
item.dom.classList.add('not-selectable');
|
||||
}
|
||||
frag.append(item.dom);
|
||||
}
|
||||
this.dom.append(frag);
|
||||
this.updatePosition();
|
||||
this.getLayer().append(this.domWrap);
|
||||
} else {
|
||||
this.domWrap.remove();
|
||||
}
|
||||
this.renderDetailsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create updated DOM for details.
|
||||
*/
|
||||
renderDetails() {
|
||||
if (!this.isActive) return this.detailsWrap.remove();
|
||||
if (!this.isShowingDetails && this.isReplaceable) return this.detailsWrap.remove();
|
||||
this.detailsDom.innerHTML = '';
|
||||
this.detailsDom.append(this.selectedItem?.renderDetails() ?? 'NO ITEM');
|
||||
this.getLayer().append(this.detailsWrap);
|
||||
this.updateDetailsPositionDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement} closest ancestor dialog or body
|
||||
*/
|
||||
getLayer() {
|
||||
return this.textarea.closest('dialog, body');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Update position of DOM.
|
||||
*/
|
||||
updatePosition() {
|
||||
if (this.isFloating) {
|
||||
this.updateFloatingPosition();
|
||||
} else {
|
||||
const rect = {};
|
||||
rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
|
||||
rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
|
||||
rect[AUTOCOMPLETE_WIDTH.FULL] = this.getLayer().getBoundingClientRect();
|
||||
this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
|
||||
this.dom.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
|
||||
this.domWrap.style.bottom = `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`;
|
||||
if (this.isShowingDetails) {
|
||||
this.domWrap.style.setProperty('--leftOffset', '1vw');
|
||||
this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`);
|
||||
this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(${rect[power_user.stscript.autocomplete.width.right].right}px, ${this.isShowingDetails ? 74 : 0}vw)`);
|
||||
} else {
|
||||
this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`);
|
||||
this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(99vw, ${rect[power_user.stscript.autocomplete.width.right].right}px)`);
|
||||
}
|
||||
}
|
||||
this.updateDetailsPosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update position of details DOM.
|
||||
*/
|
||||
updateDetailsPosition() {
|
||||
if (this.isShowingDetails || !this.isReplaceable) {
|
||||
if (this.isFloating) {
|
||||
this.updateFloatingDetailsPosition();
|
||||
} else {
|
||||
const rect = {};
|
||||
rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
|
||||
rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
|
||||
rect[AUTOCOMPLETE_WIDTH.FULL] = this.getLayer().getBoundingClientRect();
|
||||
if (this.isReplaceable) {
|
||||
this.detailsWrap.classList.remove('full');
|
||||
const selRect = this.selectedItem.dom.children[0].getBoundingClientRect();
|
||||
this.detailsWrap.style.setProperty('--targetOffset', `${selRect.top}`);
|
||||
this.detailsWrap.style.setProperty('--rightOffset', '1vw');
|
||||
this.detailsWrap.style.setProperty('--bottomOffset', `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT].top}px)`);
|
||||
this.detailsWrap.style.setProperty('--leftOffset', `calc(100vw - ${this.domWrap.style.getPropertyValue('--rightOffset')}`);
|
||||
} else {
|
||||
this.detailsWrap.classList.add('full');
|
||||
this.detailsWrap.style.setProperty('--targetOffset', `${rect[AUTOCOMPLETE_WIDTH.INPUT].top}`);
|
||||
this.detailsWrap.style.setProperty('--bottomOffset', `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT].top}px)`);
|
||||
this.detailsWrap.style.setProperty('--leftOffset', `${rect[power_user.stscript.autocomplete.width.left].left}px`);
|
||||
this.detailsWrap.style.setProperty('--rightOffset', `calc(100vw - ${rect[power_user.stscript.autocomplete.width.right].right}px)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update position of floating autocomplete.
|
||||
*/
|
||||
updateFloatingPosition() {
|
||||
const location = this.getCursorPosition();
|
||||
const rect = this.textarea.getBoundingClientRect();
|
||||
const layerRect = this.getLayer().getBoundingClientRect();
|
||||
// cursor is out of view -> hide
|
||||
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
|
||||
return this.hide();
|
||||
}
|
||||
const left = Math.max(rect.left, location.left) - layerRect.left;
|
||||
this.domWrap.style.setProperty('--targetOffset', `${left}`);
|
||||
if (location.top <= window.innerHeight / 2) {
|
||||
// if cursor is in lower half of window, show list above line
|
||||
this.domWrap.style.top = `${location.bottom - layerRect.top}px`;
|
||||
this.domWrap.style.bottom = 'auto';
|
||||
this.domWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
|
||||
} else {
|
||||
// if cursor is in upper half of window, show list below line
|
||||
this.domWrap.style.top = 'auto';
|
||||
this.domWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`;
|
||||
this.domWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
|
||||
}
|
||||
}
|
||||
|
||||
updateFloatingDetailsPosition(location = null) {
|
||||
if (!location) location = this.getCursorPosition();
|
||||
const rect = this.textarea.getBoundingClientRect();
|
||||
const layerRect = this.getLayer().getBoundingClientRect();
|
||||
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
|
||||
return this.hide();
|
||||
}
|
||||
const left = Math.max(rect.left, location.left) - layerRect.left;
|
||||
this.detailsWrap.style.setProperty('--targetOffset', `${left}`);
|
||||
if (this.isReplaceable) {
|
||||
this.detailsWrap.classList.remove('full');
|
||||
if (left < window.innerWidth / 4) {
|
||||
// if cursor is in left part of screen, show details on right of list
|
||||
this.detailsWrap.classList.add('right');
|
||||
this.detailsWrap.classList.remove('left');
|
||||
} else {
|
||||
// if cursor is in right part of screen, show details on left of list
|
||||
this.detailsWrap.classList.remove('right');
|
||||
this.detailsWrap.classList.add('left');
|
||||
}
|
||||
} else {
|
||||
this.detailsWrap.classList.remove('left');
|
||||
this.detailsWrap.classList.remove('right');
|
||||
this.detailsWrap.classList.add('full');
|
||||
}
|
||||
if (location.top <= window.innerHeight / 2) {
|
||||
// if cursor is in lower half of window, show list above line
|
||||
this.detailsWrap.style.top = `${location.bottom - layerRect.top}px`;
|
||||
this.detailsWrap.style.bottom = 'auto';
|
||||
this.detailsWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
|
||||
} else {
|
||||
// if cursor is in upper half of window, show list below line
|
||||
this.detailsWrap.style.top = 'auto';
|
||||
this.detailsWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`;
|
||||
this.detailsWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate (keyboard) cursor coordinates within textarea.
|
||||
* @returns {{left:number, top:number, bottom:number}}
|
||||
*/
|
||||
getCursorPosition() {
|
||||
const inputRect = this.textarea.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(this.textarea);
|
||||
if (!this.clone) {
|
||||
this.clone = document.createElement('div');
|
||||
for (const key of style) {
|
||||
this.clone.style[key] = style[key];
|
||||
}
|
||||
this.clone.style.position = 'fixed';
|
||||
this.clone.style.visibility = 'hidden';
|
||||
document.body.append(this.clone);
|
||||
const mo = new MutationObserver(muts => {
|
||||
if (muts.find(it => Array.from(it.removedNodes).includes(this.textarea))) {
|
||||
this.clone.remove();
|
||||
}
|
||||
});
|
||||
mo.observe(this.textarea.parentElement, { childList: true });
|
||||
}
|
||||
this.clone.style.height = `${inputRect.height}px`;
|
||||
this.clone.style.left = `${inputRect.left}px`;
|
||||
this.clone.style.top = `${inputRect.top}px`;
|
||||
this.clone.style.whiteSpace = style.whiteSpace;
|
||||
this.clone.style.tabSize = style.tabSize;
|
||||
const text = this.textarea.value;
|
||||
const before = text.slice(0, this.textarea.selectionStart);
|
||||
this.clone.textContent = before;
|
||||
const locator = document.createElement('span');
|
||||
locator.textContent = text[this.textarea.selectionStart];
|
||||
this.clone.append(locator);
|
||||
this.clone.append(text.slice(this.textarea.selectionStart + 1));
|
||||
this.clone.scrollTop = this.textarea.scrollTop;
|
||||
this.clone.scrollLeft = this.textarea.scrollLeft;
|
||||
const locatorRect = locator.getBoundingClientRect();
|
||||
const location = {
|
||||
left: locatorRect.left,
|
||||
top: locatorRect.top,
|
||||
bottom: locatorRect.bottom,
|
||||
};
|
||||
return location;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Toggle details view alongside autocomplete list.
|
||||
*/
|
||||
toggleDetails() {
|
||||
this.isShowingDetails = !this.isShowingDetails;
|
||||
this.renderDetailsDebounced();
|
||||
this.updatePosition();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Select an item for autocomplete and put text into textarea.
|
||||
*/
|
||||
async select() {
|
||||
if (this.isReplaceable && this.selectedItem.value !== null) {
|
||||
this.textarea.value = `${this.text.slice(0, this.effectiveParserResult.start)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`;
|
||||
this.textarea.selectionStart = this.effectiveParserResult.start + this.selectedItem.replacer.length;
|
||||
this.textarea.selectionEnd = this.textarea.selectionStart;
|
||||
this.show(false, false, true);
|
||||
} else {
|
||||
const selectionStart = this.textarea.selectionStart;
|
||||
const selectionEnd = this.textarea.selectionDirection;
|
||||
this.textarea.selectionStart = selectionStart;
|
||||
this.textarea.selectionDirection = selectionEnd;
|
||||
}
|
||||
this.wasForced = false;
|
||||
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
this.onSelect?.(this.selectedItem);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mark the item at newIdx in the autocomplete list as selected.
|
||||
* @param {number} newIdx
|
||||
*/
|
||||
selectItemAtIndex(newIdx) {
|
||||
this.selectedItem.dom.classList.remove('selected');
|
||||
this.selectedItem = this.result[newIdx];
|
||||
this.selectedItem.dom.classList.add('selected');
|
||||
const rect = this.selectedItem.dom.children[0].getBoundingClientRect();
|
||||
const rectParent = this.dom.getBoundingClientRect();
|
||||
if (rect.top < rectParent.top || rect.bottom > rectParent.bottom) {
|
||||
this.dom.scrollTop += rect.top < rectParent.top ? rect.top - rectParent.top : rect.bottom - rectParent.bottom;
|
||||
}
|
||||
this.renderDetailsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events.
|
||||
* @param {KeyboardEvent} evt The event.
|
||||
*/
|
||||
async handleKeyDown(evt) {
|
||||
// autocomplete is shown and cursor at end of current command name (or inside name and typed or forced)
|
||||
if (this.isActive && this.isReplaceable) {
|
||||
// actions in the list
|
||||
switch (evt.key) {
|
||||
case 'ArrowUp': {
|
||||
// select previous item
|
||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const idx = this.result.indexOf(this.selectedItem);
|
||||
let newIdx;
|
||||
if (idx == 0) newIdx = this.result.length - 1;
|
||||
else newIdx = idx - 1;
|
||||
this.selectItemAtIndex(newIdx);
|
||||
return;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
// select next item
|
||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const idx = this.result.indexOf(this.selectedItem);
|
||||
const newIdx = (idx + 1) % this.result.length;
|
||||
this.selectItemAtIndex(newIdx);
|
||||
return;
|
||||
}
|
||||
case 'Enter': {
|
||||
// pick the selected item to autocomplete
|
||||
if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.ENTER) != AUTOCOMPLETE_SELECT_KEY.ENTER) break;
|
||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break;
|
||||
if (this.selectedItem.name == this.name) break;
|
||||
if (!this.selectedItem.isSelectable) break;
|
||||
evt.preventDefault();
|
||||
evt.stopImmediatePropagation();
|
||||
this.select();
|
||||
return;
|
||||
}
|
||||
case 'Tab': {
|
||||
// pick the selected item to autocomplete
|
||||
if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.TAB) != AUTOCOMPLETE_SELECT_KEY.TAB) break;
|
||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break;
|
||||
evt.preventDefault();
|
||||
evt.stopImmediatePropagation();
|
||||
if (!this.selectedItem.isSelectable) break;
|
||||
this.select();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// details are shown, cursor can be anywhere
|
||||
if (this.isActive) {
|
||||
switch (evt.key) {
|
||||
case 'Escape': {
|
||||
// close autocomplete
|
||||
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this.isForceHidden = true;
|
||||
this.wasForced = false;
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
case 'Enter': {
|
||||
// hide autocomplete on enter (send, execute, ...)
|
||||
if (!evt.shiftKey) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// autocomplete shown or not, cursor anywhere
|
||||
switch (evt.key) {
|
||||
// The first is a non-breaking space, the second is a regular space.
|
||||
case ' ':
|
||||
case ' ': {
|
||||
if (evt.ctrlKey || evt.altKey) {
|
||||
if (this.isActive && this.isReplaceable) {
|
||||
// ctrl-space to toggle details for selected item
|
||||
this.toggleDetails();
|
||||
} else {
|
||||
// ctrl-space to force show autocomplete
|
||||
this.show(false, true);
|
||||
}
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (['Control', 'Shift', 'Alt'].includes(evt.key)) {
|
||||
// ignore keydown on modifier keys
|
||||
return;
|
||||
}
|
||||
// await keyup to see if cursor position or text has changed
|
||||
const oldText = this.textarea.value;
|
||||
await new Promise(resolve => {
|
||||
window.addEventListener('keyup', resolve, { once: true });
|
||||
});
|
||||
if (this.selectionStart != this.textarea.selectionStart) {
|
||||
this.selectionStart = this.textarea.selectionStart;
|
||||
this.show(this.isReplaceable || oldText != this.textarea.value);
|
||||
} else if (this.isActive) {
|
||||
this.text != this.textarea.value && this.show(this.isReplaceable);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
|
||||
|
||||
export class AutoCompleteFuzzyScore {
|
||||
/**@type {number}*/ start;
|
||||
/**@type {number}*/ longestConsecutive;
|
||||
|
||||
/**
|
||||
* @param {number} start
|
||||
* @param {number} longestConsecutive
|
||||
*/
|
||||
constructor(start, longestConsecutive) {
|
||||
this.start = start;
|
||||
this.longestConsecutive = longestConsecutive;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AutoCompleteNameResultBase } from './AutoCompleteNameResultBase.js';
|
||||
import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js';
|
||||
|
||||
|
||||
|
||||
export class AutoCompleteNameResult extends AutoCompleteNameResultBase {
|
||||
/**
|
||||
*
|
||||
* @param {string} text The whole text
|
||||
* @param {number} index Cursor index within text
|
||||
* @param {boolean} isSelect Whether autocomplete was triggered by selecting an autocomplete option
|
||||
* @returns {AutoCompleteSecondaryNameResult}
|
||||
*/
|
||||
getSecondaryNameAt(text, index, isSelect) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AutoCompleteOption } from './AutoCompleteOption.js';
|
||||
|
||||
|
||||
|
||||
export class AutoCompleteNameResultBase {
|
||||
/**@type {string} */ name;
|
||||
/**@type {number} */ start;
|
||||
/**@type {AutoCompleteOption[]} */ optionList = [];
|
||||
/**@type {boolean} */ canBeQuoted = false;
|
||||
/**@type {()=>string} */ makeNoMatchText = ()=>`No matches found for "${this.name}"`;
|
||||
/**@type {()=>string} */ makeNoOptionsText = ()=>'No options';
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} name Name (potentially partial) of the name at the requested index.
|
||||
* @param {number} start Index where the name starts.
|
||||
* @param {AutoCompleteOption[]} optionList A list of autocomplete options found in the current scope.
|
||||
* @param {boolean} canBeQuoted Whether the name can be inside quotes.
|
||||
* @param {()=>string} makeNoMatchText Function that returns text to show when no matches where found.
|
||||
* @param {()=>string} makeNoOptionsText Function that returns text to show when no options are available to match against.
|
||||
*/
|
||||
constructor(name, start, optionList = [], canBeQuoted = false, makeNoMatchText = null, makeNoOptionsText = null) {
|
||||
this.name = name;
|
||||
this.start = start;
|
||||
this.optionList = optionList;
|
||||
this.canBeQuoted = canBeQuoted;
|
||||
this.noMatchText = makeNoMatchText ?? this.makeNoMatchText;
|
||||
this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionsText;
|
||||
}
|
||||
}
|
||||
223
web-app/public/scripts/autocomplete/AutoCompleteOption.js
Normal file
223
web-app/public/scripts/autocomplete/AutoCompleteOption.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import { AutoCompleteFuzzyScore } from './AutoCompleteFuzzyScore.js';
|
||||
|
||||
|
||||
|
||||
export class AutoCompleteOption {
|
||||
/** @type {string} */ name;
|
||||
/** @type {string} */ typeIcon;
|
||||
/** @type {string} */ type;
|
||||
/** @type {number} */ nameOffset = 0;
|
||||
/** @type {AutoCompleteFuzzyScore} */ score;
|
||||
/** @type {string} */ replacer;
|
||||
/** @type {HTMLElement} */ dom;
|
||||
/** @type {(input:string)=>boolean} */ matchProvider;
|
||||
/** @type {(input:string)=>string} */ valueProvider;
|
||||
/** @type {boolean} */ makeSelectable = false;
|
||||
|
||||
|
||||
/**
|
||||
* Used as a comparison value when removing duplicates (e.g., when a SlashCommand has aliases).
|
||||
* @type {any}
|
||||
* */
|
||||
get value() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
get isSelectable() {
|
||||
return this.makeSelectable || !this.valueProvider;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
constructor(name, typeIcon = ' ', type = '', matchProvider = null, valueProvider = null, makeSelectable = false) {
|
||||
this.name = name;
|
||||
this.typeIcon = typeIcon;
|
||||
this.type = type;
|
||||
this.matchProvider = matchProvider;
|
||||
this.valueProvider = valueProvider;
|
||||
this.makeSelectable = makeSelectable;
|
||||
}
|
||||
|
||||
|
||||
makeItem(key, typeIcon, noSlash, namedArguments = [], unnamedArguments = [], returnType = 'void', helpString = '', aliasList = []) {
|
||||
const li = document.createElement('li'); {
|
||||
li.classList.add('item');
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('type');
|
||||
type.classList.add('monospace');
|
||||
type.textContent = typeIcon;
|
||||
li.append(type);
|
||||
}
|
||||
const specs = document.createElement('span'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('span'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = noSlash ? '' : '/';
|
||||
key.split('').forEach(char=>{
|
||||
const span = document.createElement('span'); {
|
||||
span.textContent = char;
|
||||
name.append(span);
|
||||
}
|
||||
});
|
||||
specs.append(name);
|
||||
}
|
||||
const body = document.createElement('span'); {
|
||||
body.classList.add('body');
|
||||
const args = document.createElement('span'); {
|
||||
args.classList.add('arguments');
|
||||
for (const arg of namedArguments) {
|
||||
const argItem = document.createElement('span'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('namedArgument');
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
const name = document.createElement('span'); {
|
||||
name.classList.add('argument-name');
|
||||
name.textContent = arg.name;
|
||||
argItem.append(name);
|
||||
}
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
args.append(argItem);
|
||||
}
|
||||
}
|
||||
for (const arg of unnamedArguments) {
|
||||
const argItem = document.createElement('span'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('unnamedArgument');
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
args.append(argItem);
|
||||
}
|
||||
}
|
||||
body.append(args);
|
||||
}
|
||||
const returns = document.createElement('span'); {
|
||||
returns.classList.add('returns');
|
||||
returns.textContent = returnType ?? 'void';
|
||||
// body.append(returns);
|
||||
}
|
||||
specs.append(body);
|
||||
}
|
||||
li.append(specs);
|
||||
}
|
||||
const stopgap = document.createElement('span'); {
|
||||
stopgap.classList.add('stopgap');
|
||||
stopgap.textContent = '';
|
||||
li.append(stopgap);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
const content = document.createElement('span'); {
|
||||
content.classList.add('helpContent');
|
||||
content.innerHTML = helpString;
|
||||
const text = content.textContent;
|
||||
content.innerHTML = '';
|
||||
content.textContent = text;
|
||||
help.append(content);
|
||||
}
|
||||
li.append(help);
|
||||
}
|
||||
if (aliasList.length > 0) {
|
||||
const aliases = document.createElement('span'); {
|
||||
aliases.classList.add('aliases');
|
||||
aliases.append(' (alias: ');
|
||||
for (const aliasName of aliasList) {
|
||||
const alias = document.createElement('span'); {
|
||||
alias.classList.add('monospace');
|
||||
alias.textContent = `/${aliasName}`;
|
||||
aliases.append(alias);
|
||||
}
|
||||
}
|
||||
aliases.append(')');
|
||||
// li.append(aliases);
|
||||
}
|
||||
}
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
renderItem() {
|
||||
// throw new Error(`${this.constructor.name}.renderItem() is not implemented`);
|
||||
let li;
|
||||
li = this.makeItem(this.name, this.typeIcon, true);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', this.type);
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
renderDetails() {
|
||||
// throw new Error(`${this.constructor.name}.renderDetails() is not implemented`);
|
||||
const frag = document.createDocumentFragment();
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = this.name;
|
||||
specs.append(name);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { AutoCompleteNameResultBase } from './AutoCompleteNameResultBase.js';
|
||||
|
||||
export class AutoCompleteSecondaryNameResult extends AutoCompleteNameResultBase {
|
||||
/**@type {boolean}*/ isRequired = false;
|
||||
/**@type {boolean}*/ forceMatch = true;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AutoCompleteOption } from './AutoCompleteOption.js';
|
||||
|
||||
export class BlankAutoCompleteOption extends AutoCompleteOption {
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
constructor(name) {
|
||||
super(name);
|
||||
this.dom = this.renderItem();
|
||||
}
|
||||
|
||||
get value() { return null; }
|
||||
|
||||
|
||||
renderItem() {
|
||||
const li = document.createElement('li'); {
|
||||
li.classList.add('item');
|
||||
li.classList.add('blank');
|
||||
li.textContent = this.name;
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
return frag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Enhanced macro autocomplete option for the new MacroRegistry-based system.
|
||||
* Reuses rendering logic from MacroBrowser for consistency and DRY.
|
||||
*/
|
||||
|
||||
import { AutoCompleteOption } from './AutoCompleteOption.js';
|
||||
import {
|
||||
formatMacroSignature,
|
||||
createSourceIndicator,
|
||||
createAliasIndicator,
|
||||
renderMacroDetails,
|
||||
} from '../macros/MacroBrowser.js';
|
||||
import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
|
||||
/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
|
||||
|
||||
/**
|
||||
* Macro context passed from the parser to provide cursor position info.
|
||||
* @typedef {Object} MacroAutoCompleteContext
|
||||
* @property {string} fullText - The full macro text being typed (without {{ }}).
|
||||
* @property {number} cursorOffset - Cursor position within the macro text.
|
||||
* @property {string} identifier - The macro identifier (name).
|
||||
* @property {string[]} args - Array of arguments typed so far.
|
||||
* @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
|
||||
*/
|
||||
|
||||
export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
|
||||
/** @type {MacroDefinition} */
|
||||
#macro;
|
||||
|
||||
/** @type {MacroAutoCompleteContext|null} */
|
||||
#context = null;
|
||||
|
||||
/**
|
||||
* @param {MacroDefinition} macro - The macro definition from MacroRegistry.
|
||||
* @param {MacroAutoCompleteContext} [context] - Optional context for argument hints.
|
||||
*/
|
||||
constructor(macro, context = null) {
|
||||
// Use the macro name as the autocomplete key
|
||||
super(macro.name, enumIcons.macro);
|
||||
this.#macro = macro;
|
||||
this.#context = context;
|
||||
// nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)
|
||||
this.nameOffset = 2;
|
||||
}
|
||||
|
||||
/** @returns {MacroDefinition} */
|
||||
get macro() {
|
||||
return this.#macro;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list item for the autocomplete dropdown.
|
||||
* Tight display: [icon] [signature] [description] [alias icon?] [source icon]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
renderItem() {
|
||||
const li = document.createElement('li');
|
||||
li.classList.add('item', 'macro-ac-item');
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'macro');
|
||||
|
||||
// Type icon
|
||||
const type = document.createElement('span');
|
||||
type.classList.add('type', 'monospace');
|
||||
type.textContent = '{}';
|
||||
li.append(type);
|
||||
|
||||
// Specs container (for fuzzy highlight compatibility)
|
||||
const specs = document.createElement('span');
|
||||
specs.classList.add('specs');
|
||||
|
||||
// Name with character spans for fuzzy highlighting
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.classList.add('name', 'monospace');
|
||||
|
||||
// Build signature with individual character spans (includes {{ }})
|
||||
const sigText = formatMacroSignature(this.#macro);
|
||||
for (const char of sigText) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = char;
|
||||
nameEl.append(span);
|
||||
}
|
||||
specs.append(nameEl);
|
||||
li.append(specs);
|
||||
|
||||
// Stopgap (spacer for flex layout)
|
||||
const stopgap = document.createElement('span');
|
||||
stopgap.classList.add('stopgap');
|
||||
li.append(stopgap);
|
||||
|
||||
// Help text (description)
|
||||
const help = document.createElement('span');
|
||||
help.classList.add('help');
|
||||
const content = document.createElement('span');
|
||||
content.classList.add('helpContent');
|
||||
content.textContent = this.#macro.description || '';
|
||||
help.append(content);
|
||||
li.append(help);
|
||||
|
||||
// Alias indicator icon (if this is an alias)
|
||||
const aliasIcon = createAliasIndicator(this.#macro);
|
||||
if (aliasIcon) {
|
||||
aliasIcon.classList.add('macro-ac-indicator');
|
||||
li.append(aliasIcon);
|
||||
}
|
||||
|
||||
// Source indicator icon
|
||||
const sourceIcon = createSourceIndicator(this.#macro);
|
||||
sourceIcon.classList.add('macro-ac-indicator');
|
||||
li.append(sourceIcon);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the details panel content.
|
||||
* Reuses renderMacroDetails from MacroBrowser with autocomplete-specific options.
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
// Determine current argument index for highlighting
|
||||
const currentArgIndex = this.#context?.currentArgIndex ?? -1;
|
||||
|
||||
// Render argument hint banner if we're typing an argument
|
||||
if (currentArgIndex >= 0) {
|
||||
const hint = this.#renderArgumentHint();
|
||||
if (hint) frag.append(hint);
|
||||
}
|
||||
|
||||
// Reuse MacroBrowser's renderMacroDetails with options
|
||||
const details = renderMacroDetails(this.#macro, { currentArgIndex });
|
||||
|
||||
// Add class for autocomplete-specific styling overrides
|
||||
details.classList.add('macro-ac-details');
|
||||
frag.append(details);
|
||||
|
||||
return frag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the current argument hint banner.
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
#renderArgumentHint() {
|
||||
if (!this.#context || this.#context.currentArgIndex < 0) return null;
|
||||
|
||||
const argIndex = this.#context.currentArgIndex;
|
||||
const isListArg = argIndex >= this.#macro.maxArgs;
|
||||
|
||||
// If we're beyond unnamed args and there's no list, no hint
|
||||
if (isListArg && !this.#macro.list) return null;
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.classList.add('macro-ac-arg-hint');
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.classList.add('fa-solid', 'fa-arrow-right');
|
||||
hint.append(icon);
|
||||
|
||||
if (isListArg) {
|
||||
// List argument hint
|
||||
const listIndex = argIndex - this.#macro.maxArgs + 1;
|
||||
const text = document.createElement('span');
|
||||
text.innerHTML = `<strong>List item ${listIndex}</strong>`;
|
||||
hint.append(text);
|
||||
} else {
|
||||
// Unnamed argument hint (required or optional)
|
||||
const argDef = this.#macro.unnamedArgDefs[argIndex];
|
||||
let optionalLabel = '';
|
||||
if (argDef?.optional) {
|
||||
optionalLabel = argDef.defaultValue !== undefined
|
||||
? ` <em>(optional, default: ${argDef.defaultValue === '' ? '<empty string>' : argDef.defaultValue})</em>`
|
||||
: ' <em>(optional)</em>';
|
||||
}
|
||||
const text = document.createElement('span');
|
||||
text.innerHTML = `<strong>${argDef?.name || `Argument ${argIndex + 1}`}</strong>${optionalLabel}`;
|
||||
if (argDef?.type) {
|
||||
const typeSpan = document.createElement('code');
|
||||
typeSpan.classList.add('macro-ac-hint-type');
|
||||
if (Array.isArray(argDef.type)) {
|
||||
typeSpan.textContent = argDef.type.join(' | ');
|
||||
typeSpan.title = `Accepts: ${argDef.type.join(', ')}`;
|
||||
} else {
|
||||
typeSpan.textContent = argDef.type;
|
||||
}
|
||||
text.append(' ', typeSpan);
|
||||
}
|
||||
hint.append(text);
|
||||
|
||||
if (argDef?.description) {
|
||||
const descSpan = document.createElement('span');
|
||||
descSpan.classList.add('macro-ac-hint-desc');
|
||||
descSpan.textContent = ` — ${argDef.description}`;
|
||||
hint.append(descSpan);
|
||||
}
|
||||
|
||||
if (argDef?.sampleValue) {
|
||||
const sampleSpan = document.createElement('span');
|
||||
sampleSpan.classList.add('macro-ac-hint-sample');
|
||||
sampleSpan.textContent = ` (e.g. ${argDef.sampleValue})`;
|
||||
hint.append(sampleSpan);
|
||||
}
|
||||
}
|
||||
|
||||
return hint;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the macro text to determine current argument context.
|
||||
* @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "random::a::b".
|
||||
* @param {number} cursorOffset - Cursor position within macroText.
|
||||
* @returns {MacroAutoCompleteContext}
|
||||
*/
|
||||
export function parseMacroContext(macroText, cursorOffset) {
|
||||
const parts = [];
|
||||
let currentPart = '';
|
||||
let partStart = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < macroText.length) {
|
||||
if (macroText[i] === ':' && macroText[i + 1] === ':') {
|
||||
parts.push({ text: currentPart, start: partStart, end: i });
|
||||
currentPart = '';
|
||||
i += 2;
|
||||
partStart = i;
|
||||
} else {
|
||||
currentPart += macroText[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
// Push the last part
|
||||
parts.push({ text: currentPart, start: partStart, end: macroText.length });
|
||||
|
||||
// Determine which part the cursor is in
|
||||
let currentArgIndex = -1;
|
||||
for (let idx = 0; idx < parts.length; idx++) {
|
||||
const part = parts[idx];
|
||||
if (cursorOffset >= part.start && cursorOffset <= part.end) {
|
||||
currentArgIndex = idx - 1; // -1 because first part is identifier
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If cursor is after all parts (at the end), we're in the last arg
|
||||
if (currentArgIndex === -1 && cursorOffset >= parts[parts.length - 1].end) {
|
||||
currentArgIndex = parts.length - 1;
|
||||
}
|
||||
|
||||
return {
|
||||
fullText: macroText,
|
||||
cursorOffset,
|
||||
identifier: parts[0]?.text.trim() || '',
|
||||
args: parts.slice(1).map(p => p.text),
|
||||
currentArgIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AutoCompleteOption } from './AutoCompleteOption.js';
|
||||
|
||||
export class MacroAutoCompleteOption extends AutoCompleteOption {
|
||||
/**@type {string}*/ fullName;
|
||||
/**@type {string}*/ description;
|
||||
|
||||
|
||||
constructor(name, fullName, description) {
|
||||
super(name, '{}');
|
||||
this.fullName = fullName;
|
||||
this.description = description;
|
||||
this.nameOffset = 2;
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.makeItem(`${this.fullName}`, '{}', true, [], [], null, this.description);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'macro');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = this.fullName;
|
||||
specs.append(name);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
help.innerHTML = this.description;
|
||||
frag.append(help);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
}
|
||||
973
web-app/public/scripts/backgrounds.js
Normal file
973
web-app/public/scripts/backgrounds.js
Normal file
@@ -0,0 +1,973 @@
|
||||
import { Fuse, localforage } from '../lib.js';
|
||||
import { characters, chat_metadata, eventSource, event_types, generateQuietPrompt, getCurrentChatId, getRequestHeaders, getThumbnailUrl, saveMetadata, saveSettingsDebounced, this_chid } from '../script.js';
|
||||
import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
|
||||
import { SlashCommand } from './slash-commands/SlashCommand.js';
|
||||
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
|
||||
import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension } from './utils.js';
|
||||
import { debounce_timeout } from './constants.js';
|
||||
import { t } from './i18n.js';
|
||||
import { Popup } from './popup.js';
|
||||
import { groups, selected_group } from './group-chats.js';
|
||||
import { humanizedDateTime } from './RossAscends-mods.js';
|
||||
import { deleteMediaFromServer } from './chats.js';
|
||||
|
||||
const BG_METADATA_KEY = 'custom_background';
|
||||
const LIST_METADATA_KEY = 'chat_backgrounds';
|
||||
|
||||
// A single transparent PNG pixel used as a placeholder for errored backgrounds
|
||||
const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
|
||||
const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`;
|
||||
|
||||
const THUMBNAIL_COLUMNS_MIN = 2;
|
||||
const THUMBNAIL_COLUMNS_MAX = 8;
|
||||
const THUMBNAIL_COLUMNS_DEFAULT_DESKTOP = 5;
|
||||
const THUMBNAIL_COLUMNS_DEFAULT_MOBILE = 3;
|
||||
|
||||
/**
|
||||
* Storage for frontend-generated background thumbnails.
|
||||
* This is used to store thumbnails for backgrounds that cannot be generated on the server.
|
||||
*/
|
||||
const THUMBNAIL_STORAGE = localforage.createInstance({ name: 'SillyTavern_Thumbnails' });
|
||||
|
||||
/**
|
||||
* Cache for thumbnail blob URLs.
|
||||
* @type {Map<string, string>}
|
||||
*/
|
||||
const THUMBNAIL_BLOBS = new Map();
|
||||
|
||||
const THUMBNAIL_CONFIG = {
|
||||
width: 160,
|
||||
height: 90,
|
||||
};
|
||||
|
||||
/**
|
||||
* Background source types.
|
||||
* @readonly
|
||||
* @enum {number}
|
||||
*/
|
||||
const BG_SOURCES = {
|
||||
GLOBAL: 0,
|
||||
CHAT: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping of background sources to their corresponding tab IDs.
|
||||
* @readonly
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
const BG_TABS = Object.freeze({
|
||||
[BG_SOURCES.GLOBAL]: 'bg_global_tab',
|
||||
[BG_SOURCES.CHAT]: 'bg_chat_tab',
|
||||
});
|
||||
|
||||
/**
|
||||
* Global IntersectionObserver instance for lazy loading backgrounds
|
||||
* @type {IntersectionObserver|null}
|
||||
*/
|
||||
let lazyLoadObserver = null;
|
||||
|
||||
export let background_settings = {
|
||||
name: '__transparent.png',
|
||||
url: generateUrlParameter('__transparent.png', false),
|
||||
fitting: 'classic',
|
||||
animation: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a single thumbnail DOM element. The CSS now handles all sizing.
|
||||
* @param {object} imageData - Data for the image (filename, isCustom).
|
||||
* @returns {HTMLElement} The created thumbnail element.
|
||||
*/
|
||||
function createThumbnailElement(imageData) {
|
||||
const bg = imageData.filename;
|
||||
const isCustom = imageData.isCustom;
|
||||
|
||||
const thumbnail = $('#background_template .bg_example').clone();
|
||||
|
||||
const clipper = document.createElement('div');
|
||||
clipper.className = 'thumbnail-clipper lazy-load-background';
|
||||
clipper.style.backgroundImage = PLACEHOLDER_IMAGE;
|
||||
|
||||
const titleElement = thumbnail.find('.BGSampleTitle');
|
||||
clipper.appendChild(titleElement.get(0));
|
||||
thumbnail.append(clipper);
|
||||
|
||||
const url = generateUrlParameter(bg, isCustom);
|
||||
const title = isCustom ? bg.split('/').pop() : bg;
|
||||
const friendlyTitle = title.slice(0, title.lastIndexOf('.'));
|
||||
|
||||
thumbnail.attr('title', title);
|
||||
thumbnail.attr('bgfile', bg);
|
||||
thumbnail.attr('custom', String(isCustom));
|
||||
thumbnail.data('url', url);
|
||||
titleElement.text(friendlyTitle);
|
||||
|
||||
return thumbnail.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the thumbnail column count to the CSS and updates button states.
|
||||
* @param {number} count - The number of columns to display.
|
||||
*/
|
||||
function applyThumbnailColumns(count) {
|
||||
const newCount = Math.max(THUMBNAIL_COLUMNS_MIN, Math.min(count, THUMBNAIL_COLUMNS_MAX));
|
||||
background_settings.thumbnailColumns = newCount;
|
||||
document.documentElement.style.setProperty('--bg-thumb-columns', newCount.toString());
|
||||
|
||||
$('#bg_thumb_zoom_in').prop('disabled', newCount <= THUMBNAIL_COLUMNS_MIN);
|
||||
$('#bg_thumb_zoom_out').prop('disabled', newCount >= THUMBNAIL_COLUMNS_MAX);
|
||||
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
export function loadBackgroundSettings(settings) {
|
||||
let backgroundSettings = settings.background;
|
||||
if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {
|
||||
backgroundSettings = background_settings;
|
||||
}
|
||||
if (!backgroundSettings.fitting) {
|
||||
backgroundSettings.fitting = 'classic';
|
||||
}
|
||||
if (!Object.hasOwn(backgroundSettings, 'animation')) {
|
||||
backgroundSettings.animation = false;
|
||||
}
|
||||
|
||||
// If a value is already saved, use it. Otherwise, determine default based on screen size.
|
||||
let columns = backgroundSettings.thumbnailColumns;
|
||||
if (!columns) {
|
||||
const isNarrowScreen = window.matchMedia('(max-width: 480px)').matches;
|
||||
columns = isNarrowScreen ? THUMBNAIL_COLUMNS_DEFAULT_MOBILE : THUMBNAIL_COLUMNS_DEFAULT_DESKTOP;
|
||||
}
|
||||
background_settings.thumbnailColumns = columns;
|
||||
applyThumbnailColumns(background_settings.thumbnailColumns);
|
||||
|
||||
setBackground(backgroundSettings.name, backgroundSettings.url);
|
||||
setFittingClass(backgroundSettings.fitting);
|
||||
$('#background_fitting').val(backgroundSettings.fitting);
|
||||
$('#background_thumbnails_animation').prop('checked', background_settings.animation);
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the background for the current chat and adds it to the list of custom backgrounds.
|
||||
* @param {{url: string, path:string}} backgroundInfo
|
||||
*/
|
||||
async function forceSetBackground(backgroundInfo) {
|
||||
saveBackgroundMetadata(backgroundInfo.url);
|
||||
$('#bg1').css('background-image', backgroundInfo.url);
|
||||
|
||||
const list = chat_metadata[LIST_METADATA_KEY] || [];
|
||||
const bg = backgroundInfo.path;
|
||||
list.push(bg);
|
||||
chat_metadata[LIST_METADATA_KEY] = list;
|
||||
saveMetadataDebounced();
|
||||
renderChatBackgrounds();
|
||||
highlightNewBackground(bg);
|
||||
highlightLockedBackground();
|
||||
}
|
||||
|
||||
async function onChatChanged() {
|
||||
const lockedUrl = chat_metadata[BG_METADATA_KEY];
|
||||
|
||||
$('#bg1').css('background-image', lockedUrl || background_settings.url);
|
||||
|
||||
renderChatBackgrounds();
|
||||
highlightLockedBackground();
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
|
||||
function getBackgroundPath(fileUrl) {
|
||||
return `backgrounds/${encodeURIComponent(fileUrl)}`;
|
||||
}
|
||||
|
||||
function highlightLockedBackground() {
|
||||
$('.bg_example.locked-background').removeClass('locked-background');
|
||||
|
||||
const lockedBackgroundUrl = chat_metadata[BG_METADATA_KEY];
|
||||
|
||||
if (lockedBackgroundUrl) {
|
||||
$('.bg_example').filter(function () {
|
||||
return $(this).data('url') === lockedBackgroundUrl;
|
||||
}).addClass('locked-background');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the background for the current chat
|
||||
* @param {Event|null} event
|
||||
*/
|
||||
function onLockBackgroundClick(event = null) {
|
||||
if (!getCurrentChatId()) {
|
||||
toastr.warning(t`Select a chat to lock the background for it`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Take the global background's URL and save it to the chat's metadata.
|
||||
const urlToLock = event ? $(event.target).closest('.bg_example').data('url') : background_settings.url;
|
||||
saveBackgroundMetadata(urlToLock);
|
||||
$('#bg1').css('background-image', urlToLock);
|
||||
|
||||
// Update UI states to reflect the new lock.
|
||||
highlightLockedBackground();
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks the background for the current chat
|
||||
* @param {Event|null} _event
|
||||
*/
|
||||
function onUnlockBackgroundClick(_event = null) {
|
||||
// Delete the lock from the chat's metadata.
|
||||
removeBackgroundMetadata();
|
||||
|
||||
// Revert the view to the current global background.
|
||||
$('#bg1').css('background-image', background_settings.url);
|
||||
|
||||
// Update UI states to reflect the removal of the lock.
|
||||
highlightLockedBackground();
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
|
||||
function isChatBackgroundLocked() {
|
||||
return chat_metadata[BG_METADATA_KEY];
|
||||
}
|
||||
|
||||
function saveBackgroundMetadata(file) {
|
||||
chat_metadata[BG_METADATA_KEY] = file;
|
||||
saveMetadataDebounced();
|
||||
}
|
||||
|
||||
function removeBackgroundMetadata() {
|
||||
delete chat_metadata[BG_METADATA_KEY];
|
||||
saveMetadataDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for selecting a background.
|
||||
* @param {JQuery.Event} e Event
|
||||
*/
|
||||
function onSelectBackgroundClick(e) {
|
||||
const bgFile = $(this).attr('bgfile');
|
||||
const isCustom = $(this).attr('custom') === 'true';
|
||||
const backgroundCssUrl = getUrlParameter(this);
|
||||
const bypassGlobalLock = !isCustom && e.shiftKey;
|
||||
|
||||
if ((isChatBackgroundLocked() || isCustom) && !bypassGlobalLock) {
|
||||
// If a background is locked, update the locked background directly
|
||||
saveBackgroundMetadata(backgroundCssUrl);
|
||||
$('#bg1').css('background-image', backgroundCssUrl);
|
||||
} else {
|
||||
// Otherwise, update the global background setting
|
||||
setBackground(bgFile, backgroundCssUrl);
|
||||
}
|
||||
|
||||
// Update UI highlights to reflect the changes.
|
||||
highlightLockedBackground();
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
|
||||
async function onCopyToSystemBackgroundClick(e) {
|
||||
e.stopPropagation();
|
||||
const bgNames = await getNewBackgroundName(this);
|
||||
|
||||
if (!bgNames) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bgFile = await fetch(bgNames.oldBg);
|
||||
|
||||
if (!bgFile.ok) {
|
||||
toastr.warning('Failed to copy background');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await bgFile.blob();
|
||||
const file = new File([blob], bgNames.newBg);
|
||||
const formData = new FormData();
|
||||
formData.set('avatar', file);
|
||||
|
||||
await uploadBackground(formData);
|
||||
|
||||
const list = chat_metadata[LIST_METADATA_KEY] || [];
|
||||
const index = list.indexOf(bgNames.oldBg);
|
||||
list.splice(index, 1);
|
||||
saveMetadataDebounced();
|
||||
renderChatBackgrounds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a thumbnail for the background from storage or fetches it if not available.
|
||||
* It caches the thumbnail in local storage and returns a blob URL for the thumbnail.
|
||||
* If the thumbnail cannot be fetched, it returns a transparent PNG pixel as a fallback.
|
||||
* @param {string} bg Background URL
|
||||
* @param {boolean} isCustom Is the background custom?
|
||||
* @returns {Promise<string>} Blob URL of the thumbnail
|
||||
*/
|
||||
async function getThumbnailFromStorage(bg, isCustom) {
|
||||
const cachedBlobUrl = THUMBNAIL_BLOBS.get(bg);
|
||||
if (cachedBlobUrl) {
|
||||
return cachedBlobUrl;
|
||||
}
|
||||
|
||||
const savedBlob = await THUMBNAIL_STORAGE.getItem(bg);
|
||||
if (savedBlob) {
|
||||
const savedBlobUrl = URL.createObjectURL(savedBlob);
|
||||
THUMBNAIL_BLOBS.set(bg, savedBlobUrl);
|
||||
return savedBlobUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = isCustom ? bg : getBackgroundPath(bg);
|
||||
const response = await fetch(url, { cache: 'force-cache' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Fetch failed with status: ' + response.status);
|
||||
}
|
||||
const imageBlob = await response.blob();
|
||||
const imageBase64 = await getBase64Async(imageBlob);
|
||||
const thumbnailBase64 = await createThumbnail(imageBase64, THUMBNAIL_CONFIG.width, THUMBNAIL_CONFIG.height);
|
||||
const thumbnailBlob = await fetch(thumbnailBase64).then(res => res.blob());
|
||||
await THUMBNAIL_STORAGE.setItem(bg, thumbnailBlob);
|
||||
const blobUrl = URL.createObjectURL(thumbnailBlob);
|
||||
THUMBNAIL_BLOBS.set(bg, blobUrl);
|
||||
return blobUrl;
|
||||
} catch (error) {
|
||||
console.error('Error fetching thumbnail, fallback image will be used:', error);
|
||||
const fallbackBlob = PNG_PIXEL_BLOB;
|
||||
const fallbackBlobUrl = URL.createObjectURL(fallbackBlob);
|
||||
THUMBNAIL_BLOBS.set(bg, fallbackBlobUrl);
|
||||
return fallbackBlobUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the new background name from the user.
|
||||
* @param {Element} referenceElement
|
||||
* @returns {Promise<{oldBg: string, newBg: string}>}
|
||||
* */
|
||||
async function getNewBackgroundName(referenceElement) {
|
||||
const exampleBlock = $(referenceElement).closest('.bg_example');
|
||||
const isCustom = exampleBlock.attr('custom') === 'true';
|
||||
const oldBg = exampleBlock.attr('bgfile');
|
||||
|
||||
if (!oldBg) {
|
||||
console.debug('no bgfile');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileExtension = oldBg.split('.').pop();
|
||||
const fileNameBase = isCustom ? oldBg.split('/').pop() : oldBg;
|
||||
const oldBgExtensionless = fileNameBase.replace(`.${fileExtension}`, '');
|
||||
const newBgExtensionless = await Popup.show.input(t`Enter new background name:`, null, oldBgExtensionless);
|
||||
|
||||
if (!newBgExtensionless) {
|
||||
console.debug('no new_bg_extensionless');
|
||||
return;
|
||||
}
|
||||
|
||||
const newBg = `${newBgExtensionless}.${fileExtension}`;
|
||||
|
||||
if (oldBgExtensionless === newBgExtensionless) {
|
||||
console.debug('new_bg === old_bg');
|
||||
return;
|
||||
}
|
||||
|
||||
return { oldBg, newBg };
|
||||
}
|
||||
|
||||
async function onRenameBackgroundClick(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
const bgNames = await getNewBackgroundName(this);
|
||||
|
||||
if (!bgNames) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = { old_bg: bgNames.oldBg, new_bg: bgNames.newBg };
|
||||
const response = await fetch('/api/backgrounds/rename', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await getBackgrounds();
|
||||
highlightNewBackground(bgNames.newBg);
|
||||
} else {
|
||||
toastr.warning('Failed to rename background');
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteBackgroundClick(e) {
|
||||
e.stopPropagation();
|
||||
const bgToDelete = $(this).closest('.bg_example');
|
||||
const url = bgToDelete.data('url');
|
||||
const isCustom = bgToDelete.attr('custom') === 'true';
|
||||
const deleteFromServerId = 'delete_bg_from_server';
|
||||
const customInputs = [
|
||||
{
|
||||
type: 'checkbox',
|
||||
label: t`Also delete file from server`,
|
||||
id: deleteFromServerId,
|
||||
defaultState: true,
|
||||
},
|
||||
];
|
||||
let deleteFromServer = false;
|
||||
const confirm = await Popup.show.confirm(t`Delete the background?`, null, {
|
||||
customInputs: isCustom ? customInputs : [],
|
||||
onClose: (popup) => {
|
||||
if (isCustom) {
|
||||
deleteFromServer = Boolean(popup?.inputResults?.get(deleteFromServerId) ?? false);
|
||||
}
|
||||
},
|
||||
});
|
||||
const bg = bgToDelete.attr('bgfile');
|
||||
|
||||
if (confirm) {
|
||||
// If it's not custom, it's a built-in background. Delete it from the server
|
||||
if (!isCustom) {
|
||||
await delBackground(bg);
|
||||
} else {
|
||||
const list = chat_metadata[LIST_METADATA_KEY] || [];
|
||||
const index = list.indexOf(bg);
|
||||
list.splice(index, 1);
|
||||
}
|
||||
|
||||
if (bg === background_settings.name || url === chat_metadata[BG_METADATA_KEY]) {
|
||||
const siblingSelector = '.bg_example';
|
||||
const nextBg = bgToDelete.next(siblingSelector);
|
||||
const prevBg = bgToDelete.prev(siblingSelector);
|
||||
|
||||
if (nextBg.length > 0) {
|
||||
nextBg.trigger('click');
|
||||
} else if (prevBg.length > 0) {
|
||||
prevBg.trigger('click');
|
||||
} else {
|
||||
const anyOtherBg = $('.bg_example').not(bgToDelete).first();
|
||||
if (anyOtherBg.length > 0) {
|
||||
anyOtherBg.trigger('click');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bgToDelete.remove();
|
||||
|
||||
if (url === chat_metadata[BG_METADATA_KEY]) {
|
||||
removeBackgroundMetadata();
|
||||
}
|
||||
|
||||
if (isCustom) {
|
||||
if (deleteFromServer) {
|
||||
await deleteMediaFromServer(bg);
|
||||
}
|
||||
renderChatBackgrounds();
|
||||
await saveMetadata();
|
||||
}
|
||||
|
||||
highlightLockedBackground();
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
}
|
||||
|
||||
const autoBgPrompt = 'Ignore previous instructions and choose a location ONLY from the provided list that is the most suitable for the current scene. Do not output any other text:\n{0}';
|
||||
|
||||
async function autoBackgroundCommand() {
|
||||
/** @type {HTMLElement[]} */
|
||||
const bgTitles = Array.from(document.querySelectorAll('#bg_menu_content .BGSampleTitle'));
|
||||
const options = bgTitles.map(x => ({ element: x, text: x.innerText.trim() })).filter(x => x.text.length > 0);
|
||||
if (options.length == 0) {
|
||||
toastr.warning('No backgrounds to choose from. Please upload some images to the "backgrounds" folder.');
|
||||
return '';
|
||||
}
|
||||
|
||||
const list = options.map(option => `- ${option.text}`).join('\n');
|
||||
const prompt = stringFormat(autoBgPrompt, list);
|
||||
const reply = await generateQuietPrompt({ quietPrompt: prompt });
|
||||
const fuse = new Fuse(options, { keys: ['text'] });
|
||||
const bestMatch = fuse.search(reply, { limit: 1 });
|
||||
|
||||
if (bestMatch.length == 0) {
|
||||
for (const option of options) {
|
||||
if (String(reply).toLowerCase().includes(option.text.toLowerCase())) {
|
||||
console.debug('Fallback choosing background:', option);
|
||||
option.element.click();
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
toastr.warning('No match found. Please try again.');
|
||||
return '';
|
||||
}
|
||||
|
||||
console.debug('Automatically choosing background:', bestMatch);
|
||||
bestMatch[0].item.element.click();
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the system backgrounds gallery.
|
||||
* @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
|
||||
*/
|
||||
function renderSystemBackgrounds(backgrounds) {
|
||||
const sourceList = backgrounds || [];
|
||||
const container = $('#bg_menu_content');
|
||||
container.empty();
|
||||
|
||||
if (sourceList.length === 0) return;
|
||||
|
||||
sourceList.forEach(bg => {
|
||||
const imageData = { filename: bg, isCustom: false };
|
||||
const thumbnail = createThumbnailElement(imageData);
|
||||
container.append(thumbnail);
|
||||
});
|
||||
|
||||
activateLazyLoader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the chat-specific (custom) backgrounds gallery.
|
||||
* @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
|
||||
*/
|
||||
function renderChatBackgrounds(backgrounds) {
|
||||
const sourceList = backgrounds ?? (chat_metadata[LIST_METADATA_KEY] || []);
|
||||
const container = $('#bg_custom_content');
|
||||
container.empty();
|
||||
$('#bg_chat_hint').toggle(!sourceList.length);
|
||||
|
||||
if (sourceList.length === 0) return;
|
||||
|
||||
sourceList.forEach(bg => {
|
||||
const imageData = { filename: bg, isCustom: true };
|
||||
const thumbnail = createThumbnailElement(imageData);
|
||||
container.append(thumbnail);
|
||||
});
|
||||
|
||||
activateLazyLoader();
|
||||
}
|
||||
|
||||
export async function getBackgrounds() {
|
||||
const response = await fetch('/api/backgrounds/all', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const { images, config } = await response.json();
|
||||
Object.assign(THUMBNAIL_CONFIG, config);
|
||||
|
||||
renderSystemBackgrounds(images);
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
}
|
||||
|
||||
function activateLazyLoader() {
|
||||
// Disconnect previous observer to prevent memory leaks
|
||||
if (lazyLoadObserver) {
|
||||
lazyLoadObserver.disconnect();
|
||||
lazyLoadObserver = null;
|
||||
}
|
||||
|
||||
const lazyLoadElements = document.querySelectorAll('.lazy-load-background');
|
||||
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '200px',
|
||||
threshold: 0.01,
|
||||
};
|
||||
|
||||
lazyLoadObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.target instanceof HTMLElement && entry.isIntersecting) {
|
||||
const clipper = entry.target;
|
||||
const parentThumbnail = clipper.closest('.bg_example');
|
||||
|
||||
if (parentThumbnail) {
|
||||
const bg = parentThumbnail.getAttribute('bgfile');
|
||||
const isCustom = parentThumbnail.getAttribute('custom') === 'true';
|
||||
resolveImageUrl(bg, isCustom)
|
||||
.then(url => { clipper.style.backgroundImage = url; })
|
||||
.catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; });
|
||||
}
|
||||
|
||||
clipper.classList.remove('lazy-load-background');
|
||||
observer.unobserve(clipper);
|
||||
}
|
||||
});
|
||||
}, options);
|
||||
|
||||
lazyLoadElements.forEach(element => {
|
||||
lazyLoadObserver.observe(element);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the CSS URL of the background
|
||||
* @param {Element} block
|
||||
* @returns {string} URL of the background
|
||||
*/
|
||||
function getUrlParameter(block) {
|
||||
return $(block).closest('.bg_example').data('url');
|
||||
}
|
||||
|
||||
function generateUrlParameter(bg, isCustom) {
|
||||
return isCustom ? `url("${encodeURI(bg)}")` : `url("${getBackgroundPath(bg)}")`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the image URL for the background.
|
||||
* @param {string} bg Background file name
|
||||
* @param {boolean} isCustom Is a custom background
|
||||
* @returns {Promise<string>} CSS URL of the background
|
||||
*/
|
||||
async function resolveImageUrl(bg, isCustom) {
|
||||
const fileExtension = bg.split('.').pop().toLowerCase();
|
||||
const isAnimated = ['mp4', 'webp'].includes(fileExtension);
|
||||
const thumbnailUrl = isAnimated && !background_settings.animation
|
||||
? await getThumbnailFromStorage(bg, isCustom)
|
||||
: isCustom
|
||||
? bg
|
||||
: getThumbnailUrl('bg', bg);
|
||||
|
||||
return `url("${thumbnailUrl}")`;
|
||||
}
|
||||
|
||||
async function setBackground(bg, url) {
|
||||
// Only change the visual background if one is not locked for the current chat.
|
||||
if (!isChatBackgroundLocked()) {
|
||||
$('#bg1').css('background-image', url);
|
||||
}
|
||||
background_settings.name = bg;
|
||||
background_settings.url = url;
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
async function delBackground(bg) {
|
||||
await fetch('/api/backgrounds/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({
|
||||
bg: bg,
|
||||
}),
|
||||
});
|
||||
|
||||
await THUMBNAIL_STORAGE.removeItem(bg);
|
||||
if (THUMBNAIL_BLOBS.has(bg)) {
|
||||
URL.revokeObjectURL(THUMBNAIL_BLOBS.get(bg));
|
||||
THUMBNAIL_BLOBS.delete(bg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Background upload handler.
|
||||
* @param {Event} e Event
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function onBackgroundUploadSelected(e) {
|
||||
const input = e.currentTarget;
|
||||
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
console.error('Invalid input element for background upload');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of input.files) {
|
||||
if (file.size === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
await convertFileIfVideo(formData);
|
||||
switch (getActiveBackgroundTab()) {
|
||||
case BG_SOURCES.GLOBAL:
|
||||
await uploadBackground(formData);
|
||||
break;
|
||||
case BG_SOURCES.CHAT:
|
||||
await uploadChatBackground(formData);
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown background source type');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow re-uploading the same file again by clearing the input value
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a video file to an animated webp format if the file is a video.
|
||||
* @param {FormData} formData
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function convertFileIfVideo(formData) {
|
||||
const file = formData.get('avatar');
|
||||
if (!(file instanceof File)) {
|
||||
return;
|
||||
}
|
||||
if (!file.type.startsWith('video/')) {
|
||||
return;
|
||||
}
|
||||
if (typeof globalThis.convertVideoToAnimatedWebp !== 'function') {
|
||||
toastr.warning(t`Click here to install the Video Background Loader extension`, t`Video background uploads require a downloadable add-on`, {
|
||||
timeOut: 0,
|
||||
extendedTimeOut: 0,
|
||||
onclick: () => openThirdPartyExtensionMenu('https://github.com/SillyTavern/Extension-VideoBackgroundLoader'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let toastMessage = jQuery();
|
||||
try {
|
||||
toastMessage = toastr.info(t`Preparing video for upload. This may take several minutes.`, t`Please wait`, { timeOut: 0, extendedTimeOut: 0 });
|
||||
const sourceBuffer = await file.arrayBuffer();
|
||||
const convertedBuffer = await globalThis.convertVideoToAnimatedWebp({ buffer: new Uint8Array(sourceBuffer), name: file.name });
|
||||
const convertedFileName = file.name.replace(/\.[^/.]+$/, '.webp');
|
||||
const convertedFile = new File([new Uint8Array(convertedBuffer)], convertedFileName, { type: 'image/webp' });
|
||||
formData.set('avatar', convertedFile);
|
||||
toastMessage.remove();
|
||||
} catch (error) {
|
||||
formData.delete('avatar');
|
||||
toastMessage.remove();
|
||||
console.error('Error converting video to animated webp:', error);
|
||||
toastr.error(t`Error converting video to animated webp`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a background to the server
|
||||
* @param {FormData} formData
|
||||
*/
|
||||
async function uploadBackground(formData) {
|
||||
try {
|
||||
if (!formData.has('avatar')) {
|
||||
console.log('No file provided. Background upload cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/backgrounds/upload', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders({ omitContentType: true }),
|
||||
body: formData,
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload background');
|
||||
}
|
||||
|
||||
const bg = await response.text();
|
||||
setBackground(bg, generateUrlParameter(bg, false));
|
||||
await getBackgrounds();
|
||||
highlightNewBackground(bg);
|
||||
} catch (error) {
|
||||
console.error('Error uploading background:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a chat background using a FormData object.
|
||||
* @param {FormData} formData FormData containing the background file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function uploadChatBackground(formData) {
|
||||
try {
|
||||
if (!getCurrentChatId()) {
|
||||
toastr.warning(t`Select a chat to upload a background for it`);
|
||||
return;
|
||||
}
|
||||
if (!formData.has('avatar')) {
|
||||
console.log('No file provided. Chat background upload cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = formData.get('avatar');
|
||||
if (!(file instanceof File)) {
|
||||
console.error('Invalid file type for chat background upload');
|
||||
return;
|
||||
}
|
||||
|
||||
const imageDataUri = await getBase64Async(file);
|
||||
const base64Data = imageDataUri.split(',')[1];
|
||||
const extension = getFileExtension(file);
|
||||
const characterName = selected_group
|
||||
? groups.find(g => g.id === selected_group)?.id?.toString()
|
||||
: characters[this_chid]?.name;
|
||||
const filename = `${characterName}_${humanizedDateTime()}`;
|
||||
const imagePath = await saveBase64AsFile(base64Data, characterName, filename, extension);
|
||||
|
||||
const list = chat_metadata[LIST_METADATA_KEY] || [];
|
||||
list.push(imagePath);
|
||||
chat_metadata[LIST_METADATA_KEY] = list;
|
||||
await saveMetadata();
|
||||
renderChatBackgrounds();
|
||||
highlightNewBackground(imagePath);
|
||||
highlightLockedBackground();
|
||||
highlightSelectedBackground();
|
||||
} catch (error) {
|
||||
console.error('Error uploading chat background:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bg
|
||||
*/
|
||||
function highlightNewBackground(bg) {
|
||||
const newBg = $(`.bg_example[bgfile="${bg}"]`);
|
||||
const scrollOffset = newBg.offset().top - newBg.parent().offset().top;
|
||||
$('#Backgrounds').scrollTop(scrollOffset);
|
||||
flashHighlight(newBg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the fitting class for the background element
|
||||
* @param {string} fitting Fitting type
|
||||
*/
|
||||
function setFittingClass(fitting) {
|
||||
const backgrounds = $('#bg1');
|
||||
for (const option of ['cover', 'contain', 'stretch', 'center']) {
|
||||
backgrounds.toggleClass(option, option === fitting);
|
||||
}
|
||||
background_settings.fitting = fitting;
|
||||
}
|
||||
|
||||
function highlightSelectedBackground() {
|
||||
$('.bg_example.selected-background').removeClass('selected-background');
|
||||
|
||||
// The "selected" highlight should always reflect the global background setting.
|
||||
const activeUrl = background_settings.url;
|
||||
|
||||
if (activeUrl) {
|
||||
// Find the thumbnail whose data-url attribute matches the active URL
|
||||
$('.bg_example').filter(function () {
|
||||
return $(this).data('url') === activeUrl;
|
||||
}).addClass('selected-background');
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundFilterInput() {
|
||||
const filterValue = String($('#bg-filter').val()).toLowerCase();
|
||||
$('#bg_menu_content > .bg_example, #bg_custom_content > .bg_example').each(function () {
|
||||
const $bg = $(this);
|
||||
const title = $bg.attr('title') || '';
|
||||
const hasMatch = title.toLowerCase().includes(filterValue);
|
||||
$bg.toggle(hasMatch);
|
||||
});
|
||||
}
|
||||
|
||||
const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
|
||||
|
||||
/**
|
||||
* Gets the active background tab source.
|
||||
* @returns {BG_SOURCES} Active background tab source
|
||||
*/
|
||||
export function getActiveBackgroundTab() {
|
||||
return $('#bg_tabs').tabs('option', 'active');
|
||||
}
|
||||
|
||||
export function initBackgrounds() {
|
||||
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
|
||||
eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
|
||||
|
||||
$(document)
|
||||
.off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
|
||||
.off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {
|
||||
e.stopPropagation();
|
||||
const $context = $(this).closest('.bg_example');
|
||||
const wasOpen = $context.hasClass('mobile-menu-open');
|
||||
// Close all other open menus before opening a new one.
|
||||
$('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
|
||||
if (!wasOpen) {
|
||||
$context.addClass('mobile-menu-open');
|
||||
}
|
||||
})
|
||||
.off('blur', '.bg_example.mobile-menu-open').on('blur', '.bg_example.mobile-menu-open', function () {
|
||||
if (!$(this).is(':focus-within')) {
|
||||
$(this).removeClass('mobile-menu-open');
|
||||
}
|
||||
})
|
||||
.off('click', '.jg-button').on('click', '.jg-button', function (e) {
|
||||
e.stopPropagation();
|
||||
const action = $(this).data('action');
|
||||
|
||||
switch (action) {
|
||||
case 'lock':
|
||||
onLockBackgroundClick.call(this, e.originalEvent);
|
||||
break;
|
||||
case 'unlock':
|
||||
onUnlockBackgroundClick.call(this, e.originalEvent);
|
||||
break;
|
||||
case 'edit':
|
||||
onRenameBackgroundClick.call(this, e.originalEvent);
|
||||
break;
|
||||
case 'delete':
|
||||
onDeleteBackgroundClick.call(this, e.originalEvent);
|
||||
break;
|
||||
case 'copy':
|
||||
onCopyToSystemBackgroundClick.call(this, e.originalEvent);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
$('#bg_thumb_zoom_in').on('click', () => {
|
||||
applyThumbnailColumns(background_settings.thumbnailColumns - 1);
|
||||
});
|
||||
$('#bg_thumb_zoom_out').on('click', () => {
|
||||
applyThumbnailColumns(background_settings.thumbnailColumns + 1);
|
||||
});
|
||||
$('#auto_background').on('click', autoBackgroundCommand);
|
||||
$('#add_bg_button').on('change', (e) => onBackgroundUploadSelected(e.originalEvent));
|
||||
$('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput());
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'lockbg',
|
||||
callback: () => {
|
||||
onLockBackgroundClick();
|
||||
return '';
|
||||
},
|
||||
aliases: ['bglock'],
|
||||
helpString: 'Locks a background for the currently selected chat',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'unlockbg',
|
||||
callback: () => {
|
||||
onUnlockBackgroundClick();
|
||||
return '';
|
||||
},
|
||||
aliases: ['bgunlock'],
|
||||
helpString: 'Unlocks a background for the currently selected chat',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'autobg',
|
||||
callback: autoBackgroundCommand,
|
||||
aliases: ['bgauto'],
|
||||
helpString: 'Automatically changes the background based on the chat context using the AI request prompt',
|
||||
}));
|
||||
|
||||
$('#background_fitting').on('input', function () {
|
||||
background_settings.fitting = String($(this).val());
|
||||
setFittingClass(background_settings.fitting);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
$('#background_thumbnails_animation').on('input', async function () {
|
||||
background_settings.animation = !!$(this).prop('checked');
|
||||
saveSettingsDebounced();
|
||||
|
||||
// Refresh background thumbnails
|
||||
await getBackgrounds();
|
||||
await onChatChanged();
|
||||
});
|
||||
|
||||
Object.values(BG_TABS).forEach(tabId => {
|
||||
setupScrollToTop({
|
||||
scrollContainerId: tabId,
|
||||
buttonId: 'bg-scroll-top',
|
||||
drawerId: 'Backgrounds',
|
||||
});
|
||||
});
|
||||
|
||||
$('#bg_tabs').tabs();
|
||||
}
|
||||
674
web-app/public/scripts/bookmarks.js
Normal file
674
web-app/public/scripts/bookmarks.js
Normal file
@@ -0,0 +1,674 @@
|
||||
import {
|
||||
characters,
|
||||
saveChat,
|
||||
system_message_types,
|
||||
this_chid,
|
||||
openCharacterChat,
|
||||
chat_metadata,
|
||||
getRequestHeaders,
|
||||
getThumbnailUrl,
|
||||
getCharacters,
|
||||
chat,
|
||||
saveChatConditional,
|
||||
saveItemizedPrompts,
|
||||
setActiveGroup,
|
||||
} from '../script.js';
|
||||
import { humanizedDateTime } from './RossAscends-mods.js';
|
||||
import {
|
||||
DEFAULT_AUTO_MODE_DELAY,
|
||||
group_activation_strategy,
|
||||
group_generation_mode,
|
||||
groups,
|
||||
openGroupById,
|
||||
openGroupChat,
|
||||
saveGroupBookmarkChat,
|
||||
selected_group,
|
||||
} from './group-chats.js';
|
||||
import { hideLoader, showLoader } from './loader.js';
|
||||
import { getLastMessageId } from './macros.js';
|
||||
import { Popup } from './popup.js';
|
||||
import { SlashCommand } from './slash-commands/SlashCommand.js';
|
||||
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
|
||||
import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
|
||||
import { createTagMapFromList } from './tags.js';
|
||||
import { renderTemplateAsync } from './templates.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
import {
|
||||
getUniqueName,
|
||||
isTrueBoolean,
|
||||
} from './utils.js';
|
||||
|
||||
const bookmarkNameToken = 'Checkpoint #';
|
||||
|
||||
/**
|
||||
* Gets the names of existing chats for the current character or group.
|
||||
* @returns {Promise<string[]>} - Returns a promise that resolves to an array of existing chat names.
|
||||
*/
|
||||
async function getExistingChatNames() {
|
||||
if (selected_group) {
|
||||
const group = groups.find(x => x.id == selected_group);
|
||||
if (group && Array.isArray(group.chats)) {
|
||||
return [...group.chats];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this_chid === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const character = characters[this_chid];
|
||||
if (!character) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch('/api/characters/chats', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ avatar_url: character.avatar, simple: true }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const chats = Object.values(data).map(x => x.file_name.replace('.jsonl', ''));
|
||||
return [...chats];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function getBookmarkName({ isReplace = false, forceName = null } = {}) {
|
||||
const chatNames = await getExistingChatNames();
|
||||
|
||||
const body = await renderTemplateAsync('createCheckpoint', { isReplace: isReplace });
|
||||
let name = forceName ?? await Popup.show.input('Create Checkpoint', body);
|
||||
// Special handling for confirmed empty input (=> auto-generate name)
|
||||
if (name === '') {
|
||||
for (let i = chatNames.length; i < 1000; i++) {
|
||||
name = bookmarkNameToken + i;
|
||||
if (!chatNames.includes(name)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${name} - ${humanizedDateTime()}`;
|
||||
}
|
||||
|
||||
function getMainChatName() {
|
||||
if (chat_metadata) {
|
||||
if (chat_metadata['main_chat']) {
|
||||
return chat_metadata['main_chat'];
|
||||
}
|
||||
// groups didn't support bookmarks before chat metadata was introduced
|
||||
else if (selected_group) {
|
||||
return null;
|
||||
}
|
||||
else if (characters[this_chid].chat && characters[this_chid].chat.includes(bookmarkNameToken)) {
|
||||
const tokenIndex = characters[this_chid].chat.lastIndexOf(bookmarkNameToken);
|
||||
chat_metadata['main_chat'] = characters[this_chid].chat.substring(0, tokenIndex).trim();
|
||||
return chat_metadata['main_chat'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function showBookmarksButtons() {
|
||||
try {
|
||||
if (selected_group) {
|
||||
$('#option_convert_to_group').hide();
|
||||
} else {
|
||||
$('#option_convert_to_group').show();
|
||||
}
|
||||
|
||||
if (chat_metadata['main_chat']) {
|
||||
// In bookmark chat
|
||||
$('#option_back_to_main').show();
|
||||
$('#option_new_bookmark').show();
|
||||
} else if (!selected_group && !characters[this_chid].chat) {
|
||||
// No chat recorded on character
|
||||
$('#option_back_to_main').hide();
|
||||
$('#option_new_bookmark').hide();
|
||||
} else {
|
||||
// In main chat
|
||||
$('#option_back_to_main').hide();
|
||||
$('#option_new_bookmark').show();
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$('#option_back_to_main').hide();
|
||||
$('#option_new_bookmark').hide();
|
||||
$('#option_convert_to_group').hide();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBookmarkMenu() {
|
||||
if (!chat.length) {
|
||||
toastr.warning('The chat is empty.', 'Checkpoint creation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
return await createNewBookmark(chat.length - 1);
|
||||
}
|
||||
|
||||
// Export is used by Timelines extension. Do not remove.
|
||||
export async function createBranch(mesId) {
|
||||
if (!chat.length) {
|
||||
toastr.warning('The chat is empty.', 'Branch creation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mesId < 0 || mesId >= chat.length) {
|
||||
toastr.warning('Invalid message ID.', 'Branch creation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMes = chat[mesId];
|
||||
const mainChat = selected_group ? groups?.find(x => x.id == selected_group)?.chat_id : characters[this_chid].chat;
|
||||
const newMetadata = { main_chat: mainChat };
|
||||
let name = `Branch #${mesId} - ${humanizedDateTime()}`;
|
||||
|
||||
if (selected_group) {
|
||||
await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
|
||||
} else {
|
||||
await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
|
||||
}
|
||||
// append to branches list if it exists
|
||||
// otherwise create it
|
||||
if (typeof lastMes.extra !== 'object') {
|
||||
lastMes.extra = {};
|
||||
}
|
||||
if (typeof lastMes.extra['branches'] !== 'object') {
|
||||
lastMes.extra['branches'] = [];
|
||||
}
|
||||
lastMes.extra['branches'].push(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new bookmark for a message.
|
||||
*
|
||||
* @param {number} mesId - The ID of the message.
|
||||
* @param {Object} [options={}] - Optional parameters.
|
||||
* @param {string?} [options.forceName=null] - The name to force for the bookmark.
|
||||
* @returns {Promise<string?>} - A promise that resolves to the bookmark name when the bookmark is created.
|
||||
*/
|
||||
export async function createNewBookmark(mesId, { forceName = null } = {}) {
|
||||
if (this_chid === undefined && !selected_group) {
|
||||
toastr.info('No character selected.', 'Create Checkpoint');
|
||||
return null;
|
||||
}
|
||||
if (!chat.length) {
|
||||
toastr.warning('The chat is empty.', 'Create Checkpoint');
|
||||
return null;
|
||||
}
|
||||
if (!chat[mesId]) {
|
||||
toastr.warning('Invalid message ID.', 'Create Checkpoint');
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastMes = chat[mesId];
|
||||
|
||||
if (typeof lastMes.extra !== 'object') {
|
||||
lastMes.extra = {};
|
||||
}
|
||||
|
||||
const isReplace = lastMes.extra.bookmark_link;
|
||||
|
||||
let name = await getBookmarkName({ isReplace: isReplace, forceName: forceName });
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mainChat = selected_group ? groups?.find(x => x.id == selected_group)?.chat_id : characters[this_chid].chat;
|
||||
const newMetadata = { main_chat: mainChat };
|
||||
await saveItemizedPrompts(name);
|
||||
|
||||
if (selected_group) {
|
||||
await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
|
||||
} else {
|
||||
await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
|
||||
}
|
||||
|
||||
lastMes.extra['bookmark_link'] = name;
|
||||
|
||||
const mes = $(`.mes[mesid="${mesId}"]`);
|
||||
updateBookmarkDisplay(mes, name);
|
||||
|
||||
await saveChatConditional();
|
||||
toastr.success('Click the flag icon next to the message to open the checkpoint chat.', 'Create Checkpoint', { timeOut: 10000 });
|
||||
return name;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the display of the bookmark on a chat message.
|
||||
* @param {JQuery<HTMLElement>} mes - The message element
|
||||
* @param {string?} [newBookmarkLink=null] - The new bookmark link (optional)
|
||||
*/
|
||||
export function updateBookmarkDisplay(mes, newBookmarkLink = null) {
|
||||
newBookmarkLink && mes.attr('bookmark_link', newBookmarkLink);
|
||||
const bookmarkFlag = mes.find('.mes_bookmark');
|
||||
bookmarkFlag.attr('title', `Checkpoint\n${mes.attr('bookmark_link')}\n\n${bookmarkFlag.data('tooltip')}`);
|
||||
}
|
||||
|
||||
async function backToMainChat() {
|
||||
const mainChatName = getMainChatName();
|
||||
const allChats = await getExistingChatNames();
|
||||
|
||||
if (allChats.includes(mainChatName)) {
|
||||
if (selected_group) {
|
||||
await openGroupChat(selected_group, mainChatName);
|
||||
} else {
|
||||
await openCharacterChat(mainChatName);
|
||||
}
|
||||
return mainChatName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function convertSoloToGroupChat() {
|
||||
if (selected_group) {
|
||||
console.log('Already in group. No need for conversion');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this_chid === undefined) {
|
||||
console.log('Need to have a character selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirm = await Popup.show.confirm(t`Convert to group chat`, t`Are you sure you want to convert this chat to a group chat?` + '<br />' + t`This cannot be reverted.`);
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const character = characters[this_chid];
|
||||
|
||||
// Populate group required fields
|
||||
const name = getUniqueName(`Group: ${character.name}`, y => groups.findIndex(x => x.name === y) !== -1);
|
||||
const avatar = getThumbnailUrl('avatar', character.avatar);
|
||||
const chatName = humanizedDateTime();
|
||||
const chats = [chatName];
|
||||
const members = [character.avatar];
|
||||
const favChecked = character.fav || character.fav == 'true';
|
||||
/** @type {ChatMetadata} */
|
||||
const metadata = Object.assign({}, chat_metadata);
|
||||
delete metadata.main_chat;
|
||||
/** @type {ChatHeader} */
|
||||
const chatHeader = {
|
||||
chat_metadata: metadata,
|
||||
user_name: 'unused',
|
||||
character_name: 'unused',
|
||||
};
|
||||
/** @type {Omit<Group, 'id'>} */
|
||||
const groupCreateModel = {
|
||||
name: name,
|
||||
members: members,
|
||||
avatar_url: avatar,
|
||||
allow_self_responses: false,
|
||||
activation_strategy: group_activation_strategy.NATURAL,
|
||||
disabled_members: [],
|
||||
fav: favChecked,
|
||||
chat_id: chatName,
|
||||
chats: chats,
|
||||
hideMutedSprites: false,
|
||||
generation_mode: group_generation_mode.SWAP,
|
||||
auto_mode_delay: DEFAULT_AUTO_MODE_DELAY,
|
||||
};
|
||||
|
||||
const createGroupResponse = await fetch('/api/groups/create', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(groupCreateModel),
|
||||
});
|
||||
|
||||
if (!createGroupResponse.ok) {
|
||||
console.error('Group creation unsuccessful');
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {Group} */
|
||||
const group = await createGroupResponse.json();
|
||||
|
||||
// Convert tags list and assign to group
|
||||
createTagMapFromList('#tagList', group.id);
|
||||
|
||||
// Update chars list
|
||||
await getCharacters();
|
||||
|
||||
// Convert chat to group format
|
||||
const groupChat = [...chat].map(m => structuredClone(m));
|
||||
const genIdFirst = Date.now();
|
||||
|
||||
for (let index = 0; index < groupChat.length; index++) {
|
||||
const message = groupChat[index];
|
||||
|
||||
// Skip messages we don't care about
|
||||
if (message.is_user || message.is_system || message.extra?.type === system_message_types.NARRATOR || message.force_avatar !== undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!message.extra || typeof message.extra !== 'object') {
|
||||
message.extra = {};
|
||||
}
|
||||
|
||||
// Set force fields for solo character
|
||||
message.name = character.name;
|
||||
message.original_avatar = character.avatar;
|
||||
message.force_avatar = getThumbnailUrl('avatar', character.avatar);
|
||||
// Allow regens of a single message in group
|
||||
message.extra.gen_id = genIdFirst + index;
|
||||
}
|
||||
|
||||
// Save group chat
|
||||
const createChatResponse = await fetch('/api/chats/group/save', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: chatName, chat: [chatHeader, ...groupChat] }),
|
||||
});
|
||||
|
||||
if (!createChatResponse.ok) {
|
||||
console.error('Group chat creation unsuccessful');
|
||||
toastr.error('Group chat creation unsuccessful');
|
||||
return;
|
||||
}
|
||||
|
||||
// Click on the freshly selected group to open it
|
||||
setActiveGroup(group.id);
|
||||
await openGroupById(group.id);
|
||||
|
||||
toastr.success(t`The chat has been successfully converted!`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new branch from the message with the given ID
|
||||
* @param {number} mesId Message ID
|
||||
* @returns {Promise<string?>} Branch file name
|
||||
*/
|
||||
export async function branchChat(mesId) {
|
||||
if (this_chid === undefined && !selected_group) {
|
||||
toastr.info('No character selected.', 'Create Branch');
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = await createBranch(mesId);
|
||||
await saveItemizedPrompts(fileName);
|
||||
|
||||
if (selected_group) {
|
||||
await openGroupChat(selected_group, fileName);
|
||||
} else {
|
||||
await openCharacterChat(fileName);
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
function registerBookmarksSlashCommands() {
|
||||
/**
|
||||
* Validates a message ID. (Is a number, exists as a message)
|
||||
*
|
||||
* @param {number} mesId - The message ID to validate.
|
||||
* @param {string} context - The context of the slash command. Will be used as the title of any toasts.
|
||||
* @returns {boolean} - Returns true if the message ID is valid, otherwise false.
|
||||
*/
|
||||
function validateMessageId(mesId, context) {
|
||||
if (isNaN(mesId)) {
|
||||
toastr.warning('Invalid message ID was provided', context);
|
||||
return false;
|
||||
}
|
||||
if (!chat[mesId]) {
|
||||
toastr.warning(`Message for id ${mesId} not found`, context);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'branch-create',
|
||||
returns: 'Name of the new branch',
|
||||
callback: async (args, text) => {
|
||||
const mesId = Number(args.mesId ?? text ?? getLastMessageId());
|
||||
if (!validateMessageId(mesId, 'Create Branch')) return '';
|
||||
|
||||
const branchName = await branchChat(mesId);
|
||||
return branchName ?? '';
|
||||
},
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Message ID',
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
enumProvider: commonEnumProviders.messages(),
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Create a new branch from the selected message. If no message id is provided, will use the last message.
|
||||
</div>
|
||||
<div>
|
||||
Creating a branch will automatically choose a name for the branch.<br />
|
||||
After creating the branch, the branch chat will be automatically opened.
|
||||
</div>
|
||||
<div>
|
||||
Use Checkpoints and <code>/checkpoint-create</code> instead if you do not want to jump to the new chat.
|
||||
</div>`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'checkpoint-create',
|
||||
returns: 'Name of the new checkpoint',
|
||||
callback: async (args, text) => {
|
||||
const mesId = Number(args.mesId ?? getLastMessageId());
|
||||
if (!validateMessageId(mesId, 'Create Checkpoint')) return '';
|
||||
|
||||
if (typeof text !== 'string') {
|
||||
toastr.warning('Checkpoint name must be a string or empty', 'Create Checkpoint');
|
||||
return '';
|
||||
}
|
||||
|
||||
const checkPointName = await createNewBookmark(mesId, { forceName: text });
|
||||
return checkPointName ?? '';
|
||||
},
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'mesId',
|
||||
description: 'Message ID',
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
enumProvider: commonEnumProviders.messages(),
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Checkpoint name',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Create a new checkpoint for the selected message with the provided name. If no message id is provided, will use the last message.<br />
|
||||
Leave the checkpoint name empty to auto-generate one.
|
||||
</div>
|
||||
<div>
|
||||
A created checkpoint will be permanently linked with the message.<br />
|
||||
If a checkpoint already exists, the link to it will be overwritten.<br />
|
||||
After creating the checkpoint, the checkpoint chat can be opened with the checkpoint flag,
|
||||
using the <code>/go</code> command with the checkpoint name or the <code>/checkpoint-go</code> command on the message.
|
||||
</div>
|
||||
<div>
|
||||
Use Branches and <code>/branch-create</code> instead if you do want to jump to the new chat.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example:</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<pre><code>/checkpoint-create mes={{lastCharMessage}} Checkpoint for char reply | /setvar key=rememberCheckpoint {{pipe}}</code></pre>
|
||||
Will create a new checkpoint to the latest message of the current character, and save it as a local variable for future use.
|
||||
</li>
|
||||
</ul>
|
||||
</div>`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'checkpoint-go',
|
||||
returns: 'Name of the checkpoint',
|
||||
callback: async (args, text) => {
|
||||
const mesId = Number(args.mesId ?? text ?? getLastMessageId());
|
||||
if (!validateMessageId(mesId, 'Open Checkpoint')) return '';
|
||||
|
||||
const checkPointName = chat[mesId].extra?.bookmark_link;
|
||||
if (!checkPointName) {
|
||||
toastr.warning('No checkpoint is linked to the selected message', 'Open Checkpoint');
|
||||
return '';
|
||||
}
|
||||
|
||||
if (selected_group) {
|
||||
await openGroupChat(selected_group, checkPointName);
|
||||
} else {
|
||||
await openCharacterChat(checkPointName);
|
||||
}
|
||||
|
||||
return checkPointName;
|
||||
},
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Message ID',
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
enumProvider: commonEnumProviders.messages(),
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Open the checkpoint linked to the selected message. If no message id is provided, will use the last message.
|
||||
</div>
|
||||
<div>
|
||||
Use <code>/checkpoint-get</code> if you want to make sure that the selected message has a checkpoint.
|
||||
</div>`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'checkpoint-exit',
|
||||
returns: 'The name of the chat exited to. Returns an empty string if not in a checkpoint chat.',
|
||||
callback: async () => {
|
||||
const mainChat = await backToMainChat();
|
||||
return mainChat ?? '';
|
||||
},
|
||||
helpString: 'Exit the checkpoint chat.<br />If not in a checkpoint chat, returns empty string.',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'checkpoint-parent',
|
||||
returns: 'Name of the parent chat for this checkpoint',
|
||||
callback: async () => {
|
||||
const mainChatName = getMainChatName();
|
||||
return mainChatName ?? '';
|
||||
},
|
||||
helpString: 'Get the name of the parent chat for this checkpoint.<br />If not in a checkpoint chat, returns empty string.',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'checkpoint-get',
|
||||
returns: 'Name of the chat',
|
||||
callback: async (args, text) => {
|
||||
const mesId = Number(args.mesId ?? text ?? getLastMessageId());
|
||||
if (!validateMessageId(mesId, 'Get Checkpoint')) return '';
|
||||
|
||||
const checkPointName = chat[mesId].extra?.bookmark_link;
|
||||
return checkPointName ?? '';
|
||||
},
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Message ID',
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
enumProvider: commonEnumProviders.messages(),
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Get the name of the checkpoint linked to the selected message. If no message id is provided, will use the last message.<br />
|
||||
If no checkpoint is linked, the result will be empty.
|
||||
</div>`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'checkpoint-list',
|
||||
returns: 'JSON array of all existing checkpoints in this chat, as an array',
|
||||
/** @param {{links?: string}} args @returns {Promise<string>} */
|
||||
callback: async (args, _) => {
|
||||
const result = Object.entries(chat)
|
||||
.filter(([_, message]) => message.extra?.bookmark_link)
|
||||
.map(([mesId, message]) => isTrueBoolean(args.links) ? message.extra.bookmark_link : Number(mesId));
|
||||
return JSON.stringify(result);
|
||||
},
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'links',
|
||||
description: 'Get a list of all links / chat names of the checkpoints, instead of the message ids',
|
||||
typeList: [ARGUMENT_TYPE.BOOLEAN],
|
||||
enumList: commonEnumProviders.boolean('trueFalse')(),
|
||||
defaultValue: 'false',
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
List all existing checkpoints in this chat.
|
||||
</div>
|
||||
<div>
|
||||
Returns a list of all message ids that have a checkpoint, or all checkpoint links if <code>links</code> is set to <code>true</code>.<br />
|
||||
The value will be a JSON array.
|
||||
</div>`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function initBookmarks() {
|
||||
$('#option_new_bookmark').on('click', saveBookmarkMenu);
|
||||
$('#option_back_to_main').on('click', backToMainChat);
|
||||
$('#option_convert_to_group').on('click', convertSoloToGroupChat);
|
||||
|
||||
$(document).on('click', '.select_chat_block, .mes_bookmark', async function (e) {
|
||||
// If shift is held down, we are not following the bookmark, but creating a new one
|
||||
const mes = $(this).closest('.mes');
|
||||
if (e.shiftKey && mes.length) {
|
||||
const selectedMesId = mes.attr('mesid');
|
||||
await createNewBookmark(Number(selectedMesId));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = $(this).hasClass('mes_bookmark')
|
||||
? $(this).closest('.mes').attr('bookmark_link')
|
||||
: $(this).attr('file_name').replace('.jsonl', '');
|
||||
|
||||
if (!fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showLoader();
|
||||
if (selected_group) {
|
||||
await openGroupChat(selected_group, fileName);
|
||||
} else {
|
||||
await openCharacterChat(fileName);
|
||||
}
|
||||
} finally {
|
||||
await hideLoader();
|
||||
}
|
||||
|
||||
$('#shadow_select_chat_popup').css('display', 'none');
|
||||
});
|
||||
|
||||
$(document).on('click', '.mes_create_bookmark', async function () {
|
||||
const mesId = $(this).closest('.mes').attr('mesid');
|
||||
if (mesId !== undefined) {
|
||||
await createNewBookmark(Number(mesId));
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('click', '.mes_create_branch', async function () {
|
||||
const mesId = $(this).closest('.mes').attr('mesid');
|
||||
if (mesId !== undefined) {
|
||||
await branchChat(Number(mesId));
|
||||
}
|
||||
});
|
||||
|
||||
registerBookmarksSlashCommands();
|
||||
}
|
||||
86
web-app/public/scripts/browser-fixes.js
Normal file
86
web-app/public/scripts/browser-fixes.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { getParsedUA, isMobile } from './RossAscends-mods.js';
|
||||
|
||||
const isFirefox = () => /firefox/i.test(navigator.userAgent);
|
||||
|
||||
function sanitizeInlineQuotationOnCopy() {
|
||||
// STRG+C, STRG+V on firefox leads to duplicate double quotes when inline quotation elements are copied.
|
||||
// To work around this, take the selection and transform <q> to <span> before calling toString().
|
||||
document.addEventListener('copy', function (event) {
|
||||
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection.anchorNode?.parentElement.closest('.mes_text')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0).cloneContents();
|
||||
const tempDOM = document.createDocumentFragment();
|
||||
|
||||
/**
|
||||
* Process a node, transforming <q> elements to <span> elements and preserving children.
|
||||
* @param {Node} node Input node
|
||||
* @returns {Node} Processed node
|
||||
*/
|
||||
function processNode(node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.nodeName.toLowerCase() === 'q') {
|
||||
// Transform <q> to <span>, preserve children
|
||||
const span = document.createElement('span');
|
||||
|
||||
[...node.childNodes].forEach(child => {
|
||||
const processedChild = processNode(child);
|
||||
span.appendChild(processedChild);
|
||||
});
|
||||
|
||||
return span;
|
||||
} else {
|
||||
// Nested structures containing <q> elements are unlikely
|
||||
return node.cloneNode(true);
|
||||
}
|
||||
}
|
||||
|
||||
[...range.childNodes].forEach(child => {
|
||||
const processedChild = processNode(child);
|
||||
tempDOM.appendChild(processedChild);
|
||||
});
|
||||
|
||||
const newRange = document.createRange();
|
||||
newRange.selectNodeContents(tempDOM);
|
||||
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData('text/plain', newRange.toString());
|
||||
});
|
||||
}
|
||||
|
||||
function addSafariPatch() {
|
||||
const userAgent = getParsedUA();
|
||||
console.debug('User Agent', userAgent);
|
||||
const isMobileSafari = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isDesktopSafari = userAgent?.browser?.name === 'Safari' && userAgent?.platform?.type === 'desktop';
|
||||
const isIOS = userAgent?.os?.name === 'iOS';
|
||||
|
||||
if (isIOS || isMobileSafari || isDesktopSafari) {
|
||||
document.body.classList.add('safari');
|
||||
}
|
||||
}
|
||||
|
||||
function applyBrowserFixes() {
|
||||
if (isFirefox()) {
|
||||
sanitizeInlineQuotationOnCopy();
|
||||
}
|
||||
|
||||
if (isMobile()) {
|
||||
const fixFunkyPositioning = () => {
|
||||
console.debug('[Mobile] Device viewport change detected.');
|
||||
document.documentElement.style.position = 'fixed';
|
||||
requestAnimationFrame(() => document.documentElement.style.position = '');
|
||||
};
|
||||
window.addEventListener('resize', fixFunkyPositioning);
|
||||
window.addEventListener('orientationchange', fixFunkyPositioning);
|
||||
}
|
||||
|
||||
addSafariPatch();
|
||||
}
|
||||
|
||||
export { isFirefox, applyBrowserFixes };
|
||||
128
web-app/public/scripts/bulk-edit.js
Normal file
128
web-app/public/scripts/bulk-edit.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { characterGroupOverlay } from '../script.js';
|
||||
import { BulkEditOverlay, BulkEditOverlayState, CharacterContextMenu } from './BulkEditOverlay.js';
|
||||
import { event_types, eventSource } from './events.js';
|
||||
|
||||
let is_bulk_edit = false;
|
||||
|
||||
const enableBulkEdit = () => {
|
||||
enableBulkSelect();
|
||||
characterGroupOverlay.selectState();
|
||||
// show the bulk edit option buttons
|
||||
$('.bulkEditOptionElement').show();
|
||||
is_bulk_edit = true;
|
||||
characterGroupOverlay.updateSelectedCount(0);
|
||||
};
|
||||
|
||||
const disableBulkEdit = () => {
|
||||
disableBulkSelect();
|
||||
characterGroupOverlay.browseState();
|
||||
// hide the bulk edit option buttons
|
||||
$('.bulkEditOptionElement').hide();
|
||||
is_bulk_edit = false;
|
||||
characterGroupOverlay.updateSelectedCount(0);
|
||||
};
|
||||
|
||||
const toggleBulkEditMode = (isBulkEdit) => {
|
||||
if (isBulkEdit) {
|
||||
disableBulkEdit();
|
||||
} else {
|
||||
enableBulkEdit();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles bulk edit mode on/off when the edit button is clicked.
|
||||
*/
|
||||
function onEditButtonClick() {
|
||||
console.log('Edit button clicked');
|
||||
toggleBulkEditMode(is_bulk_edit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the select state of all characters in bulk edit mode to selected. If all are selected, they'll be deselected.
|
||||
*/
|
||||
function onSelectAllButtonClick() {
|
||||
console.log('Bulk select all button clicked');
|
||||
const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass));
|
||||
let atLeastOneSelected = false;
|
||||
for (const character of characters) {
|
||||
const checked = $(character).find('.bulk_select_checkbox:checked').length > 0;
|
||||
if (!checked && character instanceof HTMLElement) {
|
||||
characterGroupOverlay.toggleSingleCharacter(character);
|
||||
atLeastOneSelected = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!atLeastOneSelected) {
|
||||
// If none was selected, trigger click on all to deselect all of them
|
||||
for(const character of characters) {
|
||||
const checked = $(character).find('.bulk_select_checkbox:checked') ?? false;
|
||||
if (checked && character instanceof HTMLElement) {
|
||||
characterGroupOverlay.toggleSingleCharacter(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all characters that have been selected via the bulk checkboxes.
|
||||
*/
|
||||
async function onDeleteButtonClick() {
|
||||
console.log('Delete button clicked');
|
||||
|
||||
// We just let the button trigger the context menu delete option
|
||||
await characterGroupOverlay.handleContextMenuDelete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables bulk selection by adding a checkbox next to each character.
|
||||
*/
|
||||
function enableBulkSelect() {
|
||||
$('#rm_print_characters_block .character_select').each((i, el) => {
|
||||
// Prevent checkbox from adding multiple times (because of stage change callback)
|
||||
if ($(el).find('.bulk_select_checkbox').length > 0) {
|
||||
return;
|
||||
}
|
||||
const checkbox = $('<input type=\'checkbox\' class=\'bulk_select_checkbox\'>');
|
||||
checkbox.on('change', () => {
|
||||
// Do something when the checkbox is changed
|
||||
});
|
||||
$(el).prepend(checkbox);
|
||||
});
|
||||
$('#rm_print_characters_block.group_overlay_mode_select .bogus_folder_select, #rm_print_characters_block.group_overlay_mode_select .group_select')
|
||||
.addClass('disabled');
|
||||
|
||||
$('#rm_print_characters_block').addClass('bulk_select');
|
||||
// We also need to disable the default click event for the character_select divs
|
||||
$(document).on('click', '.bulk_select_checkbox', function (event) {
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables bulk selection by removing the checkboxes.
|
||||
*/
|
||||
function disableBulkSelect() {
|
||||
$('.bulk_select_checkbox').remove();
|
||||
$('#rm_print_characters_block.group_overlay_mode_select .bogus_folder_select, #rm_print_characters_block.group_overlay_mode_select .group_select')
|
||||
.removeClass('disabled');
|
||||
$('#rm_print_characters_block').removeClass('bulk_select');
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point that runs on page load.
|
||||
*/
|
||||
export function initBulkEdit() {
|
||||
characterGroupOverlay.addStateChangeCallback((state) => {
|
||||
if (state === BulkEditOverlayState.select) enableBulkEdit();
|
||||
if (state === BulkEditOverlayState.browse) disableBulkEdit();
|
||||
});
|
||||
|
||||
$('#bulkEditButton').on('click', onEditButtonClick);
|
||||
$('#bulkSelectAllButton').on('click', onSelectAllButtonClick);
|
||||
$('#bulkDeleteButton').on('click', onDeleteButtonClick);
|
||||
|
||||
const characterContextMenu = new CharacterContextMenu(characterGroupOverlay);
|
||||
eventSource.on(event_types.CHARACTER_PAGE_LOADED, characterGroupOverlay.onPageLoad);
|
||||
console.debug('Character context menu initialized', characterContextMenu);
|
||||
}
|
||||
498
web-app/public/scripts/cfg-scale.js
Normal file
498
web-app/public/scripts/cfg-scale.js
Normal file
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
chat_metadata,
|
||||
substituteParams,
|
||||
this_chid,
|
||||
eventSource,
|
||||
event_types,
|
||||
saveSettingsDebounced,
|
||||
animation_duration,
|
||||
} from '../script.js';
|
||||
import { extension_settings, saveMetadataDebounced } from './extensions.js';
|
||||
import { selected_group } from './group-chats.js';
|
||||
import { getCharaFilename, delay } from './utils.js';
|
||||
import { power_user } from './power-user.js';
|
||||
|
||||
const extensionName = 'cfg';
|
||||
const defaultSettings = {
|
||||
global: {
|
||||
'guidance_scale': 1,
|
||||
'negative_prompt': '',
|
||||
},
|
||||
chara: [],
|
||||
};
|
||||
const settingType = {
|
||||
guidance_scale: 0,
|
||||
negative_prompt: 1,
|
||||
positive_prompt: 2,
|
||||
};
|
||||
|
||||
// Used for character and chat CFG values
|
||||
function updateSettings() {
|
||||
saveSettingsDebounced();
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
function setCharCfg(tempValue, setting) {
|
||||
const avatarName = getCharaFilename();
|
||||
|
||||
// Assign temp object
|
||||
let tempCharaCfg = {
|
||||
name: avatarName,
|
||||
};
|
||||
|
||||
switch (setting) {
|
||||
case settingType.guidance_scale:
|
||||
tempCharaCfg['guidance_scale'] = Number(tempValue);
|
||||
break;
|
||||
case settingType.negative_prompt:
|
||||
tempCharaCfg['negative_prompt'] = tempValue;
|
||||
break;
|
||||
case settingType.positive_prompt:
|
||||
tempCharaCfg['positive_prompt'] = tempValue;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
let existingCharaCfgIndex;
|
||||
let existingCharaCfg;
|
||||
|
||||
if (extension_settings.cfg.chara) {
|
||||
existingCharaCfgIndex = extension_settings.cfg.chara.findIndex((e) => e.name === avatarName);
|
||||
existingCharaCfg = extension_settings.cfg.chara[existingCharaCfgIndex];
|
||||
}
|
||||
|
||||
if (extension_settings.cfg.chara && existingCharaCfg) {
|
||||
const tempAssign = Object.assign(existingCharaCfg, tempCharaCfg);
|
||||
|
||||
// If both values are default, remove the entry
|
||||
if (!existingCharaCfg.useChara &&
|
||||
(tempAssign.guidance_scale ?? 1.00) === 1.00 &&
|
||||
(tempAssign.negative_prompt?.length ?? 0) === 0 &&
|
||||
(tempAssign.positive_prompt?.length ?? 0) === 0) {
|
||||
extension_settings.cfg.chara.splice(existingCharaCfgIndex, 1);
|
||||
}
|
||||
} else if (avatarName && tempValue.length > 0) {
|
||||
if (!extension_settings.cfg.chara) {
|
||||
extension_settings.cfg.chara = [];
|
||||
}
|
||||
|
||||
extension_settings.cfg.chara.push(tempCharaCfg);
|
||||
} else {
|
||||
console.debug('Character CFG error: No avatar name key could be found.');
|
||||
|
||||
// Don't save settings if something went wrong
|
||||
return false;
|
||||
}
|
||||
|
||||
updateSettings();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function setChatCfg(tempValue, setting) {
|
||||
switch (setting) {
|
||||
case settingType.guidance_scale:
|
||||
chat_metadata[metadataKeys.guidance_scale] = tempValue;
|
||||
break;
|
||||
case settingType.negative_prompt:
|
||||
chat_metadata[metadataKeys.negative_prompt] = tempValue;
|
||||
break;
|
||||
case settingType.positive_prompt:
|
||||
chat_metadata[metadataKeys.positive_prompt] = tempValue;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
saveMetadataDebounced();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: Only change CFG when character is selected
|
||||
function onCfgMenuItemClick() {
|
||||
if (!selected_group && this_chid === undefined) {
|
||||
toastr.warning('Select a character before trying to configure CFG', '', { timeOut: 2000 });
|
||||
return;
|
||||
}
|
||||
|
||||
//show CFG config if it's hidden
|
||||
if ($('#cfgConfig').css('display') !== 'flex') {
|
||||
$('#cfgConfig').addClass('resizing');
|
||||
$('#cfgConfig').css('display', 'flex');
|
||||
$('#cfgConfig').css('opacity', 0.0);
|
||||
$('#cfgConfig').transition({
|
||||
opacity: 1.0,
|
||||
duration: animation_duration,
|
||||
}, async function () {
|
||||
await delay(50);
|
||||
$('#cfgConfig').removeClass('resizing');
|
||||
});
|
||||
|
||||
//auto-open the main AN inline drawer
|
||||
if ($('#CFGBlockToggle')
|
||||
.siblings('.inline-drawer-content')
|
||||
.css('display') !== 'block') {
|
||||
$('#floatingPrompt').addClass('resizing');
|
||||
$('#CFGBlockToggle').trigger('click');
|
||||
}
|
||||
} else {
|
||||
//hide AN if it's already displayed
|
||||
$('#cfgConfig').addClass('resizing');
|
||||
$('#cfgConfig').transition({
|
||||
opacity: 0.0,
|
||||
duration: animation_duration,
|
||||
}, async function () {
|
||||
await delay(50);
|
||||
$('#cfgConfig').removeClass('resizing');
|
||||
});
|
||||
setTimeout(function () {
|
||||
$('#cfgConfig').hide();
|
||||
}, animation_duration);
|
||||
|
||||
}
|
||||
//duplicate options menu close handler from script.js
|
||||
//because this listener takes priority
|
||||
$('#options').stop().fadeOut(animation_duration);
|
||||
}
|
||||
|
||||
async function onChatChanged() {
|
||||
loadSettings();
|
||||
await modifyCharaHtml();
|
||||
}
|
||||
|
||||
// Rearrange the panel if a group chat is present
|
||||
async function modifyCharaHtml() {
|
||||
if (selected_group) {
|
||||
$('#chara_cfg_container').hide();
|
||||
$('#groupchat_cfg_use_chara_container').show();
|
||||
} else {
|
||||
$('#chara_cfg_container').show();
|
||||
$('#groupchat_cfg_use_chara_container').hide();
|
||||
// TODO: Remove chat checkbox here
|
||||
}
|
||||
}
|
||||
|
||||
// Reloads chat-specific settings
|
||||
function loadSettings() {
|
||||
// Set chat CFG if it exists
|
||||
$('#chat_cfg_guidance_scale').val(chat_metadata[metadataKeys.guidance_scale] ?? 1.0.toFixed(2));
|
||||
$('#chat_cfg_guidance_scale_counter').val(chat_metadata[metadataKeys.guidance_scale]?.toFixed(2) ?? 1.0.toFixed(2));
|
||||
$('#chat_cfg_negative_prompt').val(chat_metadata[metadataKeys.negative_prompt] ?? '');
|
||||
$('#chat_cfg_positive_prompt').val(chat_metadata[metadataKeys.positive_prompt] ?? '');
|
||||
$('#groupchat_cfg_use_chara').prop('checked', chat_metadata[metadataKeys.groupchat_individual_chars] ?? false);
|
||||
if (chat_metadata[metadataKeys.prompt_combine]?.length > 0) {
|
||||
chat_metadata[metadataKeys.prompt_combine].forEach((element) => {
|
||||
$(`input[name="cfg_prompt_combine"][value="${element}"]`)
|
||||
.prop('checked', true);
|
||||
});
|
||||
}
|
||||
|
||||
// Display the negative separator in quotes if not quoted already
|
||||
let promptSeparatorDisplay = [];
|
||||
const promptSeparator = chat_metadata[metadataKeys.prompt_separator];
|
||||
if (promptSeparator) {
|
||||
promptSeparatorDisplay.push(promptSeparator);
|
||||
if (!promptSeparator.startsWith('"')) {
|
||||
promptSeparatorDisplay.unshift('"');
|
||||
}
|
||||
|
||||
if (!promptSeparator.endsWith('"')) {
|
||||
promptSeparatorDisplay.push('"');
|
||||
}
|
||||
}
|
||||
|
||||
$('#cfg_prompt_separator').val(promptSeparatorDisplay.length === 0 ? '' : promptSeparatorDisplay.join(''));
|
||||
|
||||
$('#cfg_prompt_insertion_depth').val(chat_metadata[metadataKeys.prompt_insertion_depth] ?? 1);
|
||||
|
||||
// Set character CFG if it exists
|
||||
if (!selected_group) {
|
||||
const charaCfg = extension_settings.cfg.chara.find((e) => e.name === getCharaFilename());
|
||||
$('#chara_cfg_guidance_scale').val(charaCfg?.guidance_scale ?? 1.00);
|
||||
$('#chara_cfg_guidance_scale_counter').val(charaCfg?.guidance_scale?.toFixed(2) ?? 1.0.toFixed(2));
|
||||
$('#chara_cfg_negative_prompt').val(charaCfg?.negative_prompt ?? '');
|
||||
$('#chara_cfg_positive_prompt').val(charaCfg?.positive_prompt ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial extension settings
|
||||
async function initialLoadSettings() {
|
||||
// Create the settings if they don't exist
|
||||
extension_settings[extensionName] = extension_settings[extensionName] || {};
|
||||
if (Object.keys(extension_settings[extensionName]).length === 0) {
|
||||
Object.assign(extension_settings[extensionName], defaultSettings);
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
// Set global CFG values on load
|
||||
$('#global_cfg_guidance_scale').val(extension_settings.cfg.global.guidance_scale);
|
||||
$('#global_cfg_guidance_scale_counter').val(extension_settings.cfg.global.guidance_scale.toFixed(2));
|
||||
$('#global_cfg_negative_prompt').val(extension_settings.cfg.global.negative_prompt);
|
||||
$('#global_cfg_positive_prompt').val(extension_settings.cfg.global.positive_prompt);
|
||||
}
|
||||
|
||||
function migrateSettings() {
|
||||
let performSettingsSave = false;
|
||||
let performMetaSave = false;
|
||||
|
||||
if (power_user.guidance_scale) {
|
||||
extension_settings.cfg.global.guidance_scale = power_user.guidance_scale;
|
||||
delete power_user['guidance_scale'];
|
||||
performSettingsSave = true;
|
||||
}
|
||||
|
||||
if (power_user.negative_prompt) {
|
||||
extension_settings.cfg.global.negative_prompt = power_user.negative_prompt;
|
||||
delete power_user['negative_prompt'];
|
||||
performSettingsSave = true;
|
||||
}
|
||||
|
||||
if (chat_metadata['cfg_negative_combine']) {
|
||||
chat_metadata[metadataKeys.prompt_combine] = chat_metadata['cfg_negative_combine'];
|
||||
chat_metadata['cfg_negative_combine'] = undefined;
|
||||
performMetaSave = true;
|
||||
}
|
||||
|
||||
if (chat_metadata['cfg_negative_insertion_depth']) {
|
||||
chat_metadata[metadataKeys.prompt_insertion_depth] = chat_metadata['cfg_negative_insertion_depth'];
|
||||
chat_metadata['cfg_negative_insertion_depth'] = undefined;
|
||||
performMetaSave = true;
|
||||
}
|
||||
|
||||
if (chat_metadata['cfg_negative_separator']) {
|
||||
chat_metadata[metadataKeys.prompt_separator] = chat_metadata['cfg_negative_separator'];
|
||||
chat_metadata['cfg_negative_separator'] = undefined;
|
||||
performMetaSave = true;
|
||||
}
|
||||
|
||||
if (performSettingsSave) {
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
if (performMetaSave) {
|
||||
saveMetadataDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
// This function is called when the extension is loaded
|
||||
export function initCfg() {
|
||||
$('#CFGClose').on('click', function () {
|
||||
$('#cfgConfig').transition({
|
||||
opacity: 0,
|
||||
duration: animation_duration,
|
||||
easing: 'ease-in-out',
|
||||
});
|
||||
setTimeout(function () { $('#cfgConfig').hide(); }, animation_duration);
|
||||
});
|
||||
|
||||
$('#chat_cfg_guidance_scale').on('input', function () {
|
||||
const numberValue = Number($(this).val());
|
||||
const success = setChatCfg(numberValue, settingType.guidance_scale);
|
||||
if (success) {
|
||||
$('#chat_cfg_guidance_scale_counter').val(numberValue.toFixed(2));
|
||||
}
|
||||
});
|
||||
|
||||
$('#chat_cfg_negative_prompt').on('input', function () {
|
||||
setChatCfg($(this).val(), settingType.negative_prompt);
|
||||
});
|
||||
|
||||
$('#chat_cfg_positive_prompt').on('input', function () {
|
||||
setChatCfg($(this).val(), settingType.positive_prompt);
|
||||
});
|
||||
|
||||
$('#chara_cfg_guidance_scale').on('input', function () {
|
||||
const value = $(this).val();
|
||||
const success = setCharCfg(value, settingType.guidance_scale);
|
||||
if (success) {
|
||||
$('#chara_cfg_guidance_scale_counter').val(Number(value).toFixed(2));
|
||||
}
|
||||
});
|
||||
|
||||
$('#chara_cfg_negative_prompt').on('input', function () {
|
||||
setCharCfg($(this).val(), settingType.negative_prompt);
|
||||
});
|
||||
|
||||
$('#chara_cfg_positive_prompt').on('input', function () {
|
||||
setCharCfg($(this).val(), settingType.positive_prompt);
|
||||
});
|
||||
|
||||
$('#global_cfg_guidance_scale').on('input', function () {
|
||||
extension_settings.cfg.global.guidance_scale = Number($(this).val());
|
||||
$('#global_cfg_guidance_scale_counter').val(extension_settings.cfg.global.guidance_scale.toFixed(2));
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
$('#global_cfg_negative_prompt').on('input', function () {
|
||||
extension_settings.cfg.global.negative_prompt = $(this).val();
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
$('#global_cfg_positive_prompt').on('input', function () {
|
||||
extension_settings.cfg.global.positive_prompt = $(this).val();
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
$('input[name="cfg_prompt_combine"]').on('input', function () {
|
||||
const values = $('#cfgConfig').find('input[name="cfg_prompt_combine"]')
|
||||
.filter(':checked')
|
||||
.map(function () { return Number($(this).val()); })
|
||||
.get()
|
||||
.filter((e) => !Number.isNaN(e)) || [];
|
||||
|
||||
chat_metadata[metadataKeys.prompt_combine] = values;
|
||||
saveMetadataDebounced();
|
||||
});
|
||||
|
||||
$('#cfg_prompt_insertion_depth').on('input', function () {
|
||||
chat_metadata[metadataKeys.prompt_insertion_depth] = Number($(this).val());
|
||||
saveMetadataDebounced();
|
||||
});
|
||||
|
||||
$('#cfg_prompt_separator').on('input', function () {
|
||||
chat_metadata[metadataKeys.prompt_separator] = $(this).val();
|
||||
saveMetadataDebounced();
|
||||
});
|
||||
|
||||
$('#groupchat_cfg_use_chara').on('input', function () {
|
||||
const checked = !!$(this).prop('checked');
|
||||
chat_metadata[metadataKeys.groupchat_individual_chars] = checked;
|
||||
|
||||
if (checked) {
|
||||
toastr.info('You can edit character CFG values in their respective character chats.');
|
||||
}
|
||||
|
||||
saveMetadataDebounced();
|
||||
});
|
||||
|
||||
initialLoadSettings();
|
||||
|
||||
if (extension_settings.cfg) {
|
||||
migrateSettings();
|
||||
}
|
||||
|
||||
$('#option_toggle_CFG').on('click', onCfgMenuItemClick);
|
||||
|
||||
// Hook events
|
||||
eventSource.on(event_types.CHAT_CHANGED, async () => {
|
||||
await onChatChanged();
|
||||
});
|
||||
}
|
||||
|
||||
export const cfgType = {
|
||||
chat: 0,
|
||||
chara: 1,
|
||||
global: 2,
|
||||
};
|
||||
|
||||
export const metadataKeys = {
|
||||
guidance_scale: 'cfg_guidance_scale',
|
||||
negative_prompt: 'cfg_negative_prompt',
|
||||
positive_prompt: 'cfg_positive_prompt',
|
||||
prompt_combine: 'cfg_prompt_combine',
|
||||
groupchat_individual_chars: 'cfg_groupchat_individual_chars',
|
||||
prompt_insertion_depth: 'cfg_prompt_insertion_depth',
|
||||
prompt_separator: 'cfg_prompt_separator',
|
||||
};
|
||||
|
||||
// Gets the CFG guidance scale
|
||||
// If the guidance scale is 1, ignore the CFG prompt(s) since it won't be used anyways
|
||||
export function getGuidanceScale() {
|
||||
if (!extension_settings.cfg) {
|
||||
console.warn('CFG extension is not enabled. Skipping CFG guidance.');
|
||||
return;
|
||||
}
|
||||
|
||||
const charaCfg = extension_settings.cfg.chara?.find((e) => e.name === getCharaFilename(this_chid));
|
||||
const chatGuidanceScale = chat_metadata[metadataKeys.guidance_scale];
|
||||
const groupchatCharOverride = chat_metadata[metadataKeys.groupchat_individual_chars] ?? false;
|
||||
|
||||
if (chatGuidanceScale && chatGuidanceScale !== 1 && !groupchatCharOverride) {
|
||||
return {
|
||||
type: cfgType.chat,
|
||||
value: chatGuidanceScale,
|
||||
};
|
||||
}
|
||||
|
||||
if ((!selected_group && charaCfg || groupchatCharOverride) && charaCfg?.guidance_scale !== 1) {
|
||||
return {
|
||||
type: cfgType.chara,
|
||||
value: charaCfg.guidance_scale,
|
||||
};
|
||||
}
|
||||
|
||||
if (extension_settings.cfg.global && extension_settings.cfg.global?.guidance_scale !== 1) {
|
||||
return {
|
||||
type: cfgType.global,
|
||||
value: extension_settings.cfg.global.guidance_scale,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the CFG prompt separator.
|
||||
* @returns {string} The CFG prompt separator
|
||||
*/
|
||||
function getCustomSeparator() {
|
||||
const defaultSeparator = '\n';
|
||||
|
||||
try {
|
||||
if (chat_metadata[metadataKeys.prompt_separator]) {
|
||||
return JSON.parse(chat_metadata[metadataKeys.prompt_separator]);
|
||||
}
|
||||
|
||||
return defaultSeparator;
|
||||
} catch {
|
||||
console.warn('Invalid JSON detected for prompt separator. Using default separator.');
|
||||
return defaultSeparator;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the CFG prompt based on the guidance scale.
|
||||
* @param {{type: number, value: number}} guidanceScale The CFG guidance scale
|
||||
* @param {boolean} isNegative Whether to get the negative prompt
|
||||
* @param {boolean} quiet Whether to suppress console output
|
||||
* @returns {{value: string, depth: number}} The CFG prompt and insertion depth
|
||||
*/
|
||||
export function getCfgPrompt(guidanceScale, isNegative, quiet = false) {
|
||||
let splitCfgPrompt = [];
|
||||
|
||||
const cfgPromptCombine = chat_metadata[metadataKeys.prompt_combine] ?? [];
|
||||
if (guidanceScale.type === cfgType.chat || cfgPromptCombine.includes(cfgType.chat)) {
|
||||
splitCfgPrompt.unshift(
|
||||
substituteParams(
|
||||
chat_metadata[isNegative ? metadataKeys.negative_prompt : metadataKeys.positive_prompt],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const charaCfg = extension_settings.cfg.chara?.find((e) => e.name === getCharaFilename(this_chid));
|
||||
if (guidanceScale.type === cfgType.chara || cfgPromptCombine.includes(cfgType.chara)) {
|
||||
splitCfgPrompt.unshift(
|
||||
substituteParams(
|
||||
isNegative ? charaCfg.negative_prompt : charaCfg.positive_prompt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (guidanceScale.type === cfgType.global || cfgPromptCombine.includes(cfgType.global)) {
|
||||
splitCfgPrompt.unshift(
|
||||
substituteParams(
|
||||
isNegative ? extension_settings.cfg.global.negative_prompt : extension_settings.cfg.global.positive_prompt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const customSeparator = getCustomSeparator();
|
||||
const combinedCfgPrompt = splitCfgPrompt.filter((e) => e.length > 0).join(customSeparator);
|
||||
const insertionDepth = chat_metadata[metadataKeys.prompt_insertion_depth] ?? 1;
|
||||
!quiet && console.log(`Setting CFG with guidance scale: ${guidanceScale.value}, negatives: ${combinedCfgPrompt}`);
|
||||
|
||||
return {
|
||||
value: combinedCfgPrompt,
|
||||
depth: insertionDepth,
|
||||
};
|
||||
}
|
||||
124
web-app/public/scripts/char-data.js
Normal file
124
web-app/public/scripts/char-data.js
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @typedef {object} v2DataWorldInfoEntry
|
||||
* @property {string[]} keys - An array of primary keys associated with the entry.
|
||||
* @property {string[]} secondary_keys - An array of secondary keys associated with the entry (optional).
|
||||
* @property {string} comment - A human-readable description or explanation for the entry.
|
||||
* @property {string} content - The main content or data associated with the entry.
|
||||
* @property {boolean} constant - Indicates if the entry's content is fixed and unchangeable.
|
||||
* @property {boolean} selective - Indicates if the entry's inclusion is controlled by specific conditions.
|
||||
* @property {number} insertion_order - Defines the order in which the entry is inserted during processing.
|
||||
* @property {boolean} enabled - Controls whether the entry is currently active and used.
|
||||
* @property {string} position - Specifies the location or context where the entry applies.
|
||||
* @property {v2DataWorldInfoEntryExtensionInfos} extensions - An object containing additional details for extensions associated with the entry.
|
||||
* @property {number} id - A unique identifier assigned to the entry.
|
||||
*/
|
||||
/**
|
||||
* @typedef {object} v2DataWorldInfoEntryExtensionInfos
|
||||
* @property {number} position - The order in which the extension is applied relative to other extensions.
|
||||
* @property {boolean} exclude_recursion - Prevents the extension from being applied recursively.
|
||||
* @property {number} probability - The chance (between 0 and 1) of the extension being applied.
|
||||
* @property {boolean} useProbability - Determines if the `probability` property is used.
|
||||
* @property {number} depth - The maximum level of nesting allowed for recursive application of the extension.
|
||||
* @property {number} selectiveLogic - Defines the logic used to determine if the extension is applied selectively.
|
||||
* @property {string} group - A category or grouping for the extension.
|
||||
* @property {boolean} group_override - Overrides any existing group assignment for the extension.
|
||||
* @property {number} group_weight - A value used for prioritizing extensions within the same group.
|
||||
* @property {boolean} prevent_recursion - Completely disallows recursive application of the extension.
|
||||
* @property {boolean} delay_until_recursion - Will only be checked during recursion.
|
||||
* @property {number} scan_depth - The maximum depth to search for matches when applying the extension.
|
||||
* @property {boolean} match_whole_words - Specifies if only entire words should be matched during extension application.
|
||||
* @property {boolean} use_group_scoring - Indicates if group weight is considered when selecting extensions.
|
||||
* @property {boolean} case_sensitive - Controls whether case sensitivity is applied during matching for the extension.
|
||||
* @property {string} automation_id - An identifier used for automation purposes related to the extension.
|
||||
* @property {number} role - The specific function or purpose of the extension.
|
||||
* @property {boolean} vectorized - Indicates if the extension is optimized for vectorized processing.
|
||||
* @property {number} display_index - The order in which the extension should be displayed for user interfaces.
|
||||
* @property {boolean} match_persona_description - Wether to match against the persona description.
|
||||
* @property {boolean} match_character_description - Wether to match against the persona description.
|
||||
* @property {boolean} match_character_personality - Wether to match against the character personality.
|
||||
* @property {boolean} match_character_depth_prompt - Wether to match against the character depth prompt.
|
||||
* @property {boolean} match_scenario - Wether to match against the character scenario.
|
||||
* @property {boolean} match_creator_notes - Wether to match against the character creator notes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} v2WorldInfoBook
|
||||
* @property {string} name - the name of the book
|
||||
* @property {v2DataWorldInfoEntry[]} entries - the entries of the book
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} v2CharData
|
||||
* @property {string} name - The character's name.
|
||||
* @property {string} description - A brief description of the character.
|
||||
* @property {string} character_version - The character's data version.
|
||||
* @property {string} personality - A short summary of the character's personality traits.
|
||||
* @property {string} scenario - A description of the character's background or setting.
|
||||
* @property {string} first_mes - The character's opening message in a conversation.
|
||||
* @property {string} mes_example - An example message demonstrating the character's conversation style.
|
||||
* @property {string} creator_notes - Internal notes or comments left by the character's creator.
|
||||
* @property {string[]} tags - A list of keywords or labels associated with the character.
|
||||
* @property {string} system_prompt - The system prompt used to interact with the character.
|
||||
* @property {string} post_history_instructions - Instructions for handling the character's conversation history.
|
||||
* @property {string} creator - The name of the person who created the character.
|
||||
* @property {string[]} alternate_greetings - Additional greeting messages the character can use.
|
||||
* @property {v2WorldInfoBook} character_book - Data about the character's world or story (if applicable).
|
||||
* @property {v2CharDataExtensionInfos} extensions - Additional details specific to the character.
|
||||
*/
|
||||
/**
|
||||
* @typedef {object} v2CharDataExtensionInfos
|
||||
* @property {number} talkativeness - A numerical value indicating the character's propensity to talk.
|
||||
* @property {boolean} fav - A flag indicating whether the character is a favorite.
|
||||
* @property {string} world - The fictional world or setting where the character exists (if applicable).
|
||||
* @property {object} depth_prompt - Prompts used to explore the character's depth and complexity.
|
||||
* @property {number} depth_prompt.depth - The level of detail or nuance targeted by the prompt.
|
||||
* @property {string} depth_prompt.prompt - The actual prompt text used for deeper character interaction.
|
||||
* @property {"system" | "user" | "assistant"} depth_prompt.role - The role the character takes on during the prompted interaction (system, user, or assistant).
|
||||
* @property {RegexScriptData[]} regex_scripts - Custom regex scripts for the character.
|
||||
* // Non-standard extensions added by external tools
|
||||
* @property {string} [pygmalion_id] - The unique identifier assigned to the character by the Pygmalion.chat.
|
||||
* @property {string} [github_repo] - The gitHub repository associated with the character.
|
||||
* @property {string} [source_url] - The source URL associated with the character.
|
||||
* @property {{full_path: string}} [chub] - The Chub-specific data associated with the character.
|
||||
* @property {{source: string[]}} [risuai] - The RisuAI-specific data associated with the character.
|
||||
* @property {{positive: string, negative: string}} [sd_character_prompt] - SD-specific data associated with the character.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} RegexScriptData
|
||||
* @property {string} id - UUID of the script
|
||||
* @property {string} scriptName - The name of the script
|
||||
* @property {string} findRegex - The regex to find
|
||||
* @property {string} replaceString - The string to replace
|
||||
* @property {string[]} trimStrings - The strings to trim
|
||||
* @property {number[]} placement - The placement of the script
|
||||
* @property {boolean} disabled - Whether the script is disabled
|
||||
* @property {boolean} markdownOnly - Whether the script only applies to Markdown
|
||||
* @property {boolean} promptOnly - Whether the script only applies to prompts
|
||||
* @property {boolean} runOnEdit - Whether the script runs on edit
|
||||
* @property {number} substituteRegex - Whether the regex should be substituted
|
||||
* @property {number} minDepth - The minimum depth
|
||||
* @property {number} maxDepth - The maximum depth
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} v1CharData
|
||||
* @property {string} name - the name of the character
|
||||
* @property {string} description - the description of the character
|
||||
* @property {string} personality - a short personality description of the character
|
||||
* @property {string} scenario - a scenario description of the character
|
||||
* @property {string} first_mes - the first message in the conversation
|
||||
* @property {string} mes_example - the example message in the conversation
|
||||
* @property {string} creatorcomment - creator's notes of the character
|
||||
* @property {string[]} tags - the tags of the character
|
||||
* @property {number} talkativeness - talkativeness
|
||||
* @property {boolean|string} fav - fav
|
||||
* @property {string} create_date - create_date
|
||||
* @property {v2CharData} data - v2 data extension
|
||||
* // Non-standard extensions added by the ST server (not part of the original data)
|
||||
* @property {string} chat - name of the current chat file chat
|
||||
* @property {string} avatar - file name of the avatar image (acts as a unique identifier)
|
||||
* @property {string} json_data - the full raw JSON data of the character
|
||||
* @property {boolean?} shallow - if the data is shallow (lazy-loaded)
|
||||
*/
|
||||
export default 0;// now this file is a module
|
||||
335
web-app/public/scripts/chat-backups.js
Normal file
335
web-app/public/scripts/chat-backups.js
Normal file
@@ -0,0 +1,335 @@
|
||||
import { t } from './i18n.js';
|
||||
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
|
||||
import { getFileExtension, sortMoments, timestampToMoment } from './utils.js';
|
||||
import { displayPastChats, getRequestHeaders, importCharacterChat } from '/script.js';
|
||||
import { importGroupChat } from './group-chats.js';
|
||||
|
||||
class BackupsBrowser {
|
||||
/** @type {HTMLElement} */
|
||||
#buttonElement;
|
||||
/** @type {HTMLElement} */
|
||||
#buttonChevronIcon;
|
||||
/** @type {HTMLElement} */
|
||||
#backupsListElement;
|
||||
/** @type {AbortController} */
|
||||
#loadingAbortController;
|
||||
/** @type {boolean} */
|
||||
#isOpen = false;
|
||||
|
||||
get isOpen() {
|
||||
return this.#isOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* View a backup file content.
|
||||
* @param {string} name File name of the backup to view.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async viewBackup(name) {
|
||||
const response = await fetch('/api/backups/chat/download', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: name }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toastr.error(t`Failed to download backup, try again later.`);
|
||||
console.error('Failed to download chat backup:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @type {ChatMessage[]} */
|
||||
const parsedLines = [];
|
||||
const fileText = await response.text();
|
||||
fileText.split('\n').forEach(line => {
|
||||
try {
|
||||
/** @type {ChatMessage} */
|
||||
const lineData = JSON.parse(line);
|
||||
if (lineData?.mes) {
|
||||
parsedLines.push(lineData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse chat backup line:', error);
|
||||
}
|
||||
});
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.classList.add('text_pole', 'monospace', 'textarea_compact', 'margin0', 'height100p');
|
||||
textArea.readOnly = true;
|
||||
textArea.value = parsedLines.map(l => `${l.name} [${timestampToMoment(l.send_date).format('lll')}]\n${l.mes}`).join('\n\n\n');
|
||||
await callGenericPopup(textArea, POPUP_TYPE.TEXT, '', { allowVerticalScrolling: true, large: true, wide: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to parse chat backup content:', error);
|
||||
toastr.error(t`Failed to parse backup content.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a backup by importing it.
|
||||
* @param {string} name File name of the backup to restore.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async restoreBackup(name) {
|
||||
const response = await fetch('/api/backups/chat/download', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: name }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toastr.error(t`Failed to download backup, try again later.`);
|
||||
console.error('Failed to download chat backup:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], name, { type: 'application/octet-stream' });
|
||||
|
||||
const extension = getFileExtension(file);
|
||||
|
||||
if (extension !== 'jsonl') {
|
||||
toastr.warning(t`Only .jsonl files are supported for chat imports.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const context = SillyTavern.getContext();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set('file_type', extension);
|
||||
formData.set('avatar', file);
|
||||
formData.set('avatar_url', context.characters[context.characterId]?.avatar || '');
|
||||
formData.set('user_name', context.name1);
|
||||
formData.set('character_name', context.name2);
|
||||
|
||||
const importFn = context.groupId ? importGroupChat : importCharacterChat;
|
||||
const result = await importFn(formData, { refresh: false });
|
||||
|
||||
if (result.length === 0) {
|
||||
toastr.error(t`Failed to import chat backup, try again later.`);
|
||||
return;
|
||||
}
|
||||
|
||||
toastr.success(`Chat imported: ${result.join(', ')}`);
|
||||
await displayPastChats(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup file.
|
||||
* @param {string} name File name of the backup to delete.
|
||||
* @returns {Promise<boolean>} True if deleted, false otherwise.
|
||||
*/
|
||||
async deleteBackup(name) {
|
||||
const confirm = await Popup.show.confirm(t`Are you sure?`);
|
||||
if (!confirm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/backups/chat/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: name }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toastr.error(t`Failed to delete backup, try again later.`);
|
||||
console.error('Failed to delete chat backup:', response.statusText);
|
||||
return false;
|
||||
}
|
||||
|
||||
toastr.success(t`Backup deleted successfully.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load backups and populate the list element.
|
||||
* @param {AbortSignal} signal Signal to abort loading.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadBackupsIntoList(signal) {
|
||||
if (!this.#backupsListElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#backupsListElement.innerHTML = '';
|
||||
|
||||
const response = await fetch('/api/backups/chat/get', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to load chat backups list:', response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {import('../../src/endpoints/chats.js').ChatInfo[]} */
|
||||
const backupsList = await response.json();
|
||||
|
||||
for (const backup of backupsList.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)))) {
|
||||
const listItem = document.createElement('div');
|
||||
listItem.classList.add('chatBackupsListItem');
|
||||
|
||||
const backupName = document.createElement('div');
|
||||
backupName.textContent = backup.file_name;
|
||||
backupName.classList.add('chatBackupsListItemName');
|
||||
|
||||
const backupInfo = document.createElement('div');
|
||||
backupInfo.classList.add('chatBackupsListItemInfo');
|
||||
backupInfo.textContent = `${timestampToMoment(backup.last_mes).format('lll')} (${backup.file_size}, ${backup.chat_items} 💬)`;
|
||||
|
||||
const actionsList = document.createElement('div');
|
||||
actionsList.classList.add('chatBackupsListItemActions');
|
||||
|
||||
const viewButton = document.createElement('div');
|
||||
viewButton.classList.add('right_menu_button', 'fa-solid', 'fa-eye');
|
||||
viewButton.title = t`View backup`;
|
||||
viewButton.addEventListener('click', async () => {
|
||||
await this.viewBackup(backup.file_name);
|
||||
});
|
||||
|
||||
const restoreButton = document.createElement('div');
|
||||
restoreButton.classList.add('right_menu_button', 'fa-solid', 'fa-rotate-left');
|
||||
restoreButton.title = t`Restore backup`;
|
||||
restoreButton.addEventListener('click', async () => {
|
||||
await this.restoreBackup(backup.file_name);
|
||||
});
|
||||
|
||||
const deleteButton = document.createElement('div');
|
||||
deleteButton.classList.add('right_menu_button', 'fa-solid', 'fa-trash');
|
||||
deleteButton.title = t`Delete backup`;
|
||||
deleteButton.addEventListener('click',async () => {
|
||||
const isDeleted = await this.deleteBackup(backup.file_name);
|
||||
if (isDeleted) {
|
||||
listItem.remove();
|
||||
}
|
||||
});
|
||||
|
||||
actionsList.appendChild(viewButton);
|
||||
actionsList.appendChild(restoreButton);
|
||||
actionsList.appendChild(deleteButton);
|
||||
|
||||
listItem.appendChild(backupName);
|
||||
listItem.appendChild(backupInfo);
|
||||
listItem.appendChild(actionsList);
|
||||
|
||||
this.#backupsListElement.appendChild(listItem);
|
||||
}
|
||||
}
|
||||
|
||||
closeBackups() {
|
||||
if (!this.#isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isOpen = false;
|
||||
if (this.#buttonChevronIcon) {
|
||||
this.#buttonChevronIcon.classList.remove('fa-chevron-up');
|
||||
this.#buttonChevronIcon.classList.add('fa-chevron-down');
|
||||
}
|
||||
if (this.#backupsListElement) {
|
||||
this.#backupsListElement.classList.remove('open');
|
||||
this.#backupsListElement.innerHTML = '';
|
||||
}
|
||||
if (this.#loadingAbortController) {
|
||||
this.#loadingAbortController.abort();
|
||||
this.#loadingAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
openBackups() {
|
||||
if (this.#isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#isOpen = true;
|
||||
if (this.#buttonChevronIcon) {
|
||||
this.#buttonChevronIcon.classList.remove('fa-chevron-down');
|
||||
this.#buttonChevronIcon.classList.add('fa-chevron-up');
|
||||
}
|
||||
if (this.#backupsListElement) {
|
||||
this.#backupsListElement.classList.add('open');
|
||||
}
|
||||
if (this.#loadingAbortController) {
|
||||
this.#loadingAbortController.abort();
|
||||
this.#loadingAbortController = null;
|
||||
}
|
||||
|
||||
this.#loadingAbortController = new AbortController();
|
||||
this.loadBackupsIntoList(this.#loadingAbortController.signal);
|
||||
}
|
||||
|
||||
renderButton() {
|
||||
if (this.#buttonElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sibling = document.getElementById('select_chat_search');
|
||||
if (!sibling) {
|
||||
console.error('Could not find sibling element for BackupsBrowser button');
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.classList.add('menu_button', 'menu_button_icon');
|
||||
|
||||
const buttonIcon = document.createElement('i');
|
||||
buttonIcon.classList.add('fa-solid', 'fa-box-open');
|
||||
|
||||
const buttonText = document.createElement('span');
|
||||
buttonText.textContent = t`Backups`;
|
||||
buttonText.title = t`Browse chat backups`;
|
||||
|
||||
const chevronIcon = document.createElement('i');
|
||||
chevronIcon.classList.add('fa-solid', 'fa-chevron-down', 'fa-sm');
|
||||
|
||||
button.appendChild(buttonIcon);
|
||||
button.appendChild(buttonText);
|
||||
button.appendChild(chevronIcon);
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
if (this.#isOpen) {
|
||||
this.closeBackups();
|
||||
} else {
|
||||
this.openBackups();
|
||||
}
|
||||
});
|
||||
|
||||
sibling.parentNode.insertBefore(button, sibling);
|
||||
|
||||
this.#buttonElement = button;
|
||||
this.#buttonChevronIcon = chevronIcon;
|
||||
}
|
||||
|
||||
renderBackupsList() {
|
||||
if (this.#backupsListElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sibling = document.getElementById('select_chat_div');
|
||||
if (!sibling) {
|
||||
console.error('Could not find sibling element for BackupsBrowser list');
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.classList.add('chatBackupsList');
|
||||
|
||||
sibling.parentNode.insertBefore(list, sibling);
|
||||
this.#backupsListElement = list;
|
||||
}
|
||||
}
|
||||
|
||||
const backupsBrowser = new BackupsBrowser();
|
||||
|
||||
export function addChatBackupsBrowser() {
|
||||
backupsBrowser.renderButton();
|
||||
backupsBrowser.renderBackupsList();
|
||||
|
||||
// Refresh the backups list if it's already open
|
||||
if (backupsBrowser.isOpen) {
|
||||
backupsBrowser.closeBackups();
|
||||
backupsBrowser.openBackups();
|
||||
}
|
||||
}
|
||||
198
web-app/public/scripts/chat-templates.js
Normal file
198
web-app/public/scripts/chat-templates.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// the hash can be obtained from command line e.g. via: MODEL=path_to_model; python -c "import json, hashlib, sys; print(hashlib.sha256(json.load(open('"$MODEL"/tokenizer_config.json'))['chat_template'].encode()).hexdigest())"
|
||||
// note that chat templates must be trimmed to match the llama.cpp metadata value
|
||||
const hash_derivations = {
|
||||
// Meta
|
||||
'e10ca381b1ccc5cf9db52e371f3b6651576caee0a630b452e2816b2d404d4b65':
|
||||
// Meta-Llama-3.1-8B-Instruct
|
||||
// Meta-Llama-3.1-70B-Instruct
|
||||
'Llama 3 Instruct'
|
||||
,
|
||||
'5816fce10444e03c2e9ee1ef8a4a1ea61ae7e69e438613f3b17b69d0426223a4':
|
||||
// Llama-3.2-1B-Instruct
|
||||
// Llama-3.2-3B-Instruct
|
||||
'Llama 3 Instruct'
|
||||
,
|
||||
'73e87b1667d87ab7d7b579107f01151b29ce7f3ccdd1018fdc397e78be76219d':
|
||||
// Nemotron 70B
|
||||
'Llama 3 Instruct'
|
||||
,
|
||||
|
||||
// Mistral
|
||||
// Mistral Reference: https://github.com/mistralai/mistral-common
|
||||
'e16746b40344d6c5b5265988e0328a0bf7277be86f1c335156eae07e29c82826':
|
||||
// Mistral-Small-Instruct-2409
|
||||
// Mistral-Large-Instruct-2407
|
||||
'Mistral V2 & V3'
|
||||
,
|
||||
'26a59556925c987317ce5291811ba3b7f32ec4c647c400c6cc7e3a9993007ba7':
|
||||
// Mistral-7B-Instruct-v0.3
|
||||
'Mistral V2 & V3'
|
||||
,
|
||||
'e4676cb56dffea7782fd3e2b577cfaf1e123537e6ef49b3ec7caa6c095c62272':
|
||||
// Mistral-Nemo-Instruct-2407
|
||||
'Mistral V3-Tekken'
|
||||
,
|
||||
'3c4ad5fa60dd8c7ccdf82fa4225864c903e107728fcaf859fa6052cb80c92ee9':
|
||||
// Mistral-Large-Instruct-2411
|
||||
'Mistral V7'
|
||||
,
|
||||
'3934d199bfe5b6fab5cba1b5f8ee475e8d5738ac315f21cb09545b4e665cc005':
|
||||
// Mistral Small 24B
|
||||
'Mistral V7'
|
||||
,
|
||||
|
||||
// Gemma
|
||||
'ecd6ae513fe103f0eb62e8ab5bfa8d0fe45c1074fa398b089c93a7e70c15cfd6':
|
||||
// gemma-2-9b-it
|
||||
// gemma-2-27b-it
|
||||
'Gemma 2'
|
||||
,
|
||||
'87fa45af6cdc3d6a9e4dd34a0a6848eceaa73a35dcfe976bd2946a5822a38bf3':
|
||||
// gemma-2-2b-it
|
||||
'Gemma 2'
|
||||
,
|
||||
'7de1c58e208eda46e9c7f86397df37ec49883aeece39fb961e0a6b24088dd3c4':
|
||||
// gemma-3
|
||||
'Gemma 2'
|
||||
,
|
||||
|
||||
// Cohere
|
||||
'3b54f5c219ae1caa5c0bb2cdc7c001863ca6807cf888e4240e8739fa7eb9e02e':
|
||||
// command-r-08-2024
|
||||
'Command R'
|
||||
,
|
||||
|
||||
// Tulu
|
||||
'ac7498a36a719da630e99d48e6ebc4409de85a77556c2b6159eeb735bcbd11df':
|
||||
// Tulu-3-8B
|
||||
// Tulu-3-70B
|
||||
'Tulu'
|
||||
,
|
||||
|
||||
// DeepSeek V2.5
|
||||
'54d400beedcd17f464e10063e0577f6f798fa896266a912d8a366f8a2fcc0bca':
|
||||
'DeepSeek-V2.5'
|
||||
,
|
||||
|
||||
// DeepSeek R1
|
||||
'b6835114b7303ddd78919a82e4d9f7d8c26ed0d7dfc36beeb12d524f6144eab1':
|
||||
'DeepSeek-V2.5'
|
||||
,
|
||||
|
||||
// THUDM-GLM 4
|
||||
'854b703e44ca06bdb196cc471c728d15dbab61e744fe6cdce980086b61646ed1':
|
||||
'GLM-4'
|
||||
,
|
||||
|
||||
// Kimi K2, ...
|
||||
'aab20feb9bc6881f941ea649356130ffbc4943b3c2577c0991e1fba90de5a0fc':
|
||||
'Moonshot AI'
|
||||
,
|
||||
|
||||
// gpt-oss (unsloth)
|
||||
'70da0d2348e40aaf8dad05f04a316835fd10547bd7e3392ce337e4c79ba91c01':
|
||||
'OpenAI Harmony'
|
||||
,
|
||||
|
||||
// gpt-oss (ggml-org)
|
||||
'a4c9919cbbd4acdd51ccffe22da049264b1b73e59055fa58811a99efbd7c8146':
|
||||
'OpenAI Harmony'
|
||||
,
|
||||
};
|
||||
|
||||
const substr_derivations = [
|
||||
['Moonshot AI', ['<|im_user|>user<|im_middle|>', '<|im_assistant|>assistant<|im_middle|>', '<|im_end|>']],
|
||||
['OpenAI Harmony', ['<|start|>user<|message|>', '<|start|>assistant<|channel|>final<|message|>', '<|end|>']],
|
||||
|
||||
// Generic cases
|
||||
['ChatML', ['<|im_start|>user', '<|im_start|>assistant', '<|im_end|>']],
|
||||
];
|
||||
|
||||
const parse_derivation = derivation => (typeof derivation === 'string') ? {
|
||||
'context': derivation,
|
||||
'instruct': derivation,
|
||||
} : derivation;
|
||||
|
||||
const not_found = { context: null, instruct: null };
|
||||
|
||||
export async function deriveTemplatesFromChatTemplate(chat_template, hash) {
|
||||
if (chat_template.trim() === '') {
|
||||
console.log('Missing chat template.');
|
||||
return not_found;
|
||||
}
|
||||
|
||||
if (hash in hash_derivations) {
|
||||
return parse_derivation(hash_derivations[hash]);
|
||||
}
|
||||
|
||||
// heuristics
|
||||
for (const [derivation, substr] of substr_derivations) {
|
||||
if ([substr].flat().every(str => chat_template.includes(str))) {
|
||||
return parse_derivation(derivation);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Unknown chat template hash: ${hash} for [${chat_template}]`);
|
||||
return not_found;
|
||||
}
|
||||
|
||||
export async function bindModelTemplates(power_user, online_status) {
|
||||
if (online_status === 'no_connection') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chatTemplateHash = power_user.chat_template_hash;
|
||||
const bindModelTemplates = power_user.model_templates_mappings[online_status]
|
||||
?? power_user.model_templates_mappings[chatTemplateHash]
|
||||
?? {};
|
||||
const bindingsMatch = bindModelTemplates
|
||||
&& power_user.context.preset == bindModelTemplates['context']
|
||||
&& (!power_user.instruct.enabled || power_user.instruct.preset === bindModelTemplates['instruct']);
|
||||
|
||||
const bound = [];
|
||||
|
||||
if (bindingsMatch) {
|
||||
// unmap current preset
|
||||
delete power_user.model_templates_mappings[chatTemplateHash];
|
||||
delete power_user.model_templates_mappings[online_status];
|
||||
toastr.info(t`Context preset for ${online_status} will use defaults when loaded the next time.`);
|
||||
} else {
|
||||
if (power_user.context_derived) {
|
||||
if (power_user.context.preset !== bindModelTemplates['context']) {
|
||||
bound.push(`${power_user.context.preset} context preset`);
|
||||
// toastr.info(`Bound ${power_user.context.preset} preset to currently loaded model and all models that share its chat template.`);
|
||||
|
||||
// map current preset to current chat template hash
|
||||
bindModelTemplates['context'] = power_user.context.preset;
|
||||
}
|
||||
} else {
|
||||
toastr.warning(t`Note: Context derivation is disabled. Not including context preset.`);
|
||||
}
|
||||
if (power_user.instruct.enabled) {
|
||||
if (power_user.instruct_derived) {
|
||||
if (power_user.instruct.preset !== bindModelTemplates['instruct']) {
|
||||
bound.push(`${power_user.instruct.preset} instruct preset`);
|
||||
bindModelTemplates['instruct'] = power_user.instruct.preset;
|
||||
}
|
||||
} else {
|
||||
toastr.warning(t`Note: Instruct derivation is disabled. Not including instruct preset.`);
|
||||
}
|
||||
}
|
||||
if (bound.length == 0) {
|
||||
toastr.warning(t`No applicable presets available.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
toastr.info(t`Bound ${online_status} to ${bound.join(', ')}.`);
|
||||
if (!online_status.startsWith('koboldcpp/ggml-model-')) {
|
||||
power_user.model_templates_mappings[online_status] = bindModelTemplates;
|
||||
}
|
||||
if (chatTemplateHash !== '') {
|
||||
power_user.model_templates_mappings[chatTemplateHash] = bindModelTemplates;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
2417
web-app/public/scripts/chats.js
Normal file
2417
web-app/public/scripts/chats.js
Normal file
File diff suppressed because it is too large
Load Diff
188
web-app/public/scripts/constants.js
Normal file
188
web-app/public/scripts/constants.js
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Common debounce timeout values to use with `debounce` calls.
|
||||
* @readonly
|
||||
* @enum {number}
|
||||
*/
|
||||
export const debounce_timeout = {
|
||||
/** [100 ms] For ultra-fast responses, typically for keypresses or executions that might happen multiple times in a loop or recursion. */
|
||||
quick: 100,
|
||||
/** [200 ms] Slightly slower than quick, but still very responsive. */
|
||||
short: 200,
|
||||
/** [300 ms] Default time for general use, good balance between responsiveness and performance. */
|
||||
standard: 300,
|
||||
/** [1.000 ms] For situations where the function triggers more intensive tasks. */
|
||||
relaxed: 1000,
|
||||
/** [5 sec] For delayed tasks, like auto-saving or completing batch operations that need a significant pause. */
|
||||
extended: 5000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Used as an ephemeral key in message extra metadata.
|
||||
* When set, the message will be excluded from generation
|
||||
* prompts without affecting the number of chat messages,
|
||||
* which is needed to preserve world info timed effects.
|
||||
*/
|
||||
export const IGNORE_SYMBOL = Symbol.for('ignore');
|
||||
|
||||
/**
|
||||
* Common video file extensions. Should be the same as supported by Gemini.
|
||||
* https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats
|
||||
*/
|
||||
export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg'];
|
||||
|
||||
/**
|
||||
* Known generation triggers that can be passed to Generate function.
|
||||
*/
|
||||
export const GENERATION_TYPE_TRIGGERS = [
|
||||
'normal',
|
||||
'continue',
|
||||
'impersonate',
|
||||
'swipe',
|
||||
'regenerate',
|
||||
'quiet',
|
||||
];
|
||||
|
||||
/**
|
||||
* Known injection IDs and helper functions for system extensions handling.
|
||||
*/
|
||||
export const inject_ids = {
|
||||
STORY_STRING: '__STORY_STRING__',
|
||||
QUIET_PROMPT: 'QUIET_PROMPT',
|
||||
DEPTH_PROMPT: 'DEPTH_PROMPT',
|
||||
DEPTH_PROMPT_INDEX: (index) => `DEPTH_PROMPT_${index}`,
|
||||
CUSTOM_WI_DEPTH: 'customDepthWI',
|
||||
CUSTOM_WI_DEPTH_ROLE: (depth, role) => `customDepthWI_${depth}_${role}`,
|
||||
CUSTOM_WI_OUTLET: (key) => `customWIOutlet_${key}`,
|
||||
};
|
||||
|
||||
export const COMETAPI_IGNORE_PATTERNS = [
|
||||
// Image generation models
|
||||
'dall-e', 'dalle', 'midjourney', 'mj_', 'stable-diffusion', 'sd-',
|
||||
'flux-', 'playground-v', 'ideogram', 'recraft-', 'black-forest-labs',
|
||||
'/recraft-v3', 'recraftv3', 'stability-ai/', 'sdxl',
|
||||
// Audio generation models
|
||||
'suno_', 'tts', 'whisper',
|
||||
// Video generation models
|
||||
'runway', 'luma_', 'luma-', 'veo', 'kling_', 'minimax_video', 'hunyuan-t1',
|
||||
// Utility models
|
||||
'embedding', 'search-gpts', 'files_retrieve', 'moderation',
|
||||
];
|
||||
|
||||
/**
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const MEDIA_SOURCE = {
|
||||
API: 'api',
|
||||
UPLOAD: 'upload',
|
||||
GENERATED: 'generated',
|
||||
CAPTIONED: 'captioned',
|
||||
};
|
||||
|
||||
/**
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const MEDIA_DISPLAY = {
|
||||
LIST: 'list',
|
||||
GALLERY: 'gallery',
|
||||
};
|
||||
|
||||
/**
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const IMAGE_OVERSWIPE = {
|
||||
GENERATE: 'generate',
|
||||
ROLLOVER: 'rollover',
|
||||
};
|
||||
|
||||
/**
|
||||
* @readonly
|
||||
*/
|
||||
export const MEDIA_TYPE = {
|
||||
getFromMime: (/** @type {string} */ mimeType) => {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return MEDIA_TYPE.IMAGE;
|
||||
}
|
||||
if (mimeType.startsWith('video/')) {
|
||||
return MEDIA_TYPE.VIDEO;
|
||||
}
|
||||
if (mimeType.startsWith('audio/')) {
|
||||
return MEDIA_TYPE.AUDIO;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
IMAGE: 'image',
|
||||
VIDEO: 'video',
|
||||
AUDIO: 'audio',
|
||||
};
|
||||
|
||||
/**
|
||||
* Bitwise flag-style media request types.
|
||||
* @readonly
|
||||
* @enum {number}
|
||||
*/
|
||||
export const MEDIA_REQUEST_TYPE = {
|
||||
IMAGE: 0b001,
|
||||
VIDEO: 0b010,
|
||||
AUDIO: 0b100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll behavior options when appending media to messages.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const SCROLL_BEHAVIOR = {
|
||||
NONE: 'none',
|
||||
KEEP: 'keep',
|
||||
ADJUST: 'adjust',
|
||||
};
|
||||
|
||||
/**
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const OVERSWIPE_BEHAVIOR = {
|
||||
/** The overswipe right chevron will not be displayed. */
|
||||
NONE: 'none',
|
||||
/** An overswipe will loop to the first swipe. */
|
||||
LOOP: 'loop',
|
||||
/** Pristine greetings will loop, and chevrons will always be shown: https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3557893373 */
|
||||
PRISTINE_GREETING: 'pristine_greeting',
|
||||
/** If chat tree is enabled, then an overswipe will allow the user to edit the message before starting a new generation. */
|
||||
EDIT_GENERATE: 'edit_generate',
|
||||
/** This is the default behavior on character messages. */
|
||||
REGENERATE: 'regenerate',
|
||||
};
|
||||
|
||||
/**
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const SWIPE_DIRECTION = {
|
||||
LEFT: 'left',
|
||||
RIGHT: 'right',
|
||||
};
|
||||
|
||||
/**
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const SWIPE_SOURCE = {
|
||||
DELETE: 'delete',
|
||||
KEYBOARD: 'keyboard',
|
||||
BACK: 'back',
|
||||
AUTO_SWIPE: 'auto_swipe',
|
||||
};
|
||||
|
||||
/**
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const SWIPE_STATE = {
|
||||
NONE: 'none',
|
||||
SWIPING: 'swiping',
|
||||
EDITING: 'editing',
|
||||
};
|
||||
606
web-app/public/scripts/custom-request.js
Normal file
606
web-app/public/scripts/custom-request.js
Normal file
@@ -0,0 +1,606 @@
|
||||
import { getPresetManager } from './preset-manager.js';
|
||||
import { extractJsonFromData, extractMessageFromData, getGenerateUrl, getRequestHeaders, name1, name2 } from '../script.js';
|
||||
import { getTextGenServer, createTextGenGenerationData, setting_names, textgenerationwebui_settings } from './textgen-settings.js';
|
||||
import { extractReasoningFromData } from './reasoning.js';
|
||||
import { formatInstructModeChat, formatInstructModePrompt, getInstructStoppingSequences } from './instruct-mode.js';
|
||||
import { getStreamingReply, tryParseStreamingError, createGenerationParameters, settingsToUpdate, oai_settings } from './openai.js';
|
||||
import EventSourceStream from './sse-stream.js';
|
||||
|
||||
// #region Type Definitions
|
||||
/**
|
||||
* @typedef {Object} TextCompletionRequestBase
|
||||
* @property {boolean?} [stream=false] - Whether to stream the response
|
||||
* @property {number} max_tokens - Maximum number of tokens to generate
|
||||
* @property {string} [model] - Optional model name
|
||||
* @property {string} api_type - Type of API to use
|
||||
* @property {string} [api_server] - Optional API server URL
|
||||
* @property {number} [temperature] - Optional temperature parameter
|
||||
* @property {number} [min_p] - Optional min_p parameter
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TextCompletionPayloadBase
|
||||
* @property {boolean?} [stream=false] - Whether to stream the response
|
||||
* @property {string} prompt - The text prompt for completion
|
||||
* @property {number} max_tokens - Maximum number of tokens to generate
|
||||
* @property {number} max_new_tokens - Alias for max_tokens
|
||||
* @property {string} [model] - Optional model name
|
||||
* @property {string} api_type - Type of API to use
|
||||
* @property {string} api_server - API server URL
|
||||
* @property {number} [temperature] - Optional temperature parameter
|
||||
*/
|
||||
|
||||
/** @typedef {Record<string, any> & TextCompletionPayloadBase} TextCompletionPayload */
|
||||
|
||||
/**
|
||||
* @typedef {Object} ChatCompletionMessage
|
||||
* @property {string} [name] - The name of the message author (optional)
|
||||
* @property {string} role - The role of the message author (e.g., "user", "assistant", "system")
|
||||
* @property {string} content - The content of the message
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ChatCompletionPayloadBase
|
||||
* @property {boolean?} [stream=false] - Whether to stream the response
|
||||
* @property {ChatCompletionMessage[]} messages - Array of chat messages
|
||||
* @property {string} [model] - Optional model name to use for completion
|
||||
* @property {string} chat_completion_source - Source provider
|
||||
* @property {number} max_tokens - Maximum number of tokens to generate
|
||||
* @property {number} [temperature] - Optional temperature parameter for response randomness
|
||||
* @property {string} [custom_url] - Optional custom URL
|
||||
* @property {string} [reverse_proxy] - Optional reverse proxy URL
|
||||
* @property {string} [proxy_password] - Optional proxy password
|
||||
* @property {string} [custom_prompt_post_processing] - Optional custom prompt post-processing
|
||||
*/
|
||||
|
||||
/** @typedef {Record<string, any> & ChatCompletionPayloadBase} ChatCompletionPayload */
|
||||
|
||||
/**
|
||||
* @typedef {Object} ExtractedData
|
||||
* @property {string} content - Extracted content.
|
||||
* @property {string} reasoning - Extracted reasoning.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StreamResponse
|
||||
* @property {string} text - Generated text.
|
||||
* @property {string[]} swipes - Generated swipes
|
||||
* @property {Object} state - Generated state
|
||||
* @property {string?} [state.reasoning] - Generated reasoning
|
||||
* @property {string?} [state.image] - Generated image
|
||||
*/
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Creates & sends a text completion request.
|
||||
*/
|
||||
export class TextCompletionService {
|
||||
static TYPE = 'textgenerationwebui';
|
||||
|
||||
/**
|
||||
* @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom
|
||||
* @returns {TextCompletionPayload}
|
||||
*/
|
||||
static createRequestData({ stream = false, prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) {
|
||||
const payload = {
|
||||
stream,
|
||||
prompt,
|
||||
max_tokens,
|
||||
max_new_tokens: max_tokens,
|
||||
model,
|
||||
api_type,
|
||||
api_server: api_server ?? getTextGenServer(api_type),
|
||||
temperature,
|
||||
min_p,
|
||||
...props,
|
||||
};
|
||||
|
||||
// Remove undefined values to avoid API errors
|
||||
Object.keys(payload).forEach(key => {
|
||||
if (payload[key] === undefined) {
|
||||
delete payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a text completion request to the specified server
|
||||
* @param {TextCompletionPayload} data Request data
|
||||
* @param {boolean?} extractData Extract message from the response. Default true
|
||||
* @param {AbortSignal?} signal
|
||||
* @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
|
||||
* @throws {Error}
|
||||
*/
|
||||
static async sendRequest(data, extractData = true, signal = null) {
|
||||
if (!data.stream) {
|
||||
const response = await fetch(getGenerateUrl(this.TYPE), {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
cache: 'no-cache',
|
||||
body: JSON.stringify(data),
|
||||
signal: signal ?? new AbortController().signal,
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
if (!response.ok || json.error) {
|
||||
throw new Error(String(json.error?.message || 'Response not OK'));
|
||||
}
|
||||
|
||||
if (!extractData) {
|
||||
return json;
|
||||
}
|
||||
|
||||
return {
|
||||
content: extractMessageFromData(json, this.TYPE),
|
||||
reasoning: extractReasoningFromData(json, {
|
||||
mainApi: this.TYPE,
|
||||
textGenType: data.api_type,
|
||||
ignoreShowThoughts: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch('/api/backends/text-completions/generate', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
cache: 'no-cache',
|
||||
body: JSON.stringify(data),
|
||||
signal: signal ?? new AbortController().signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
tryParseStreamingError(response, text, { quiet: true });
|
||||
|
||||
throw new Error(`Got response status ${response.status}`);
|
||||
}
|
||||
|
||||
const eventStream = new EventSourceStream();
|
||||
response.body.pipeThrough(eventStream);
|
||||
const reader = eventStream.readable.getReader();
|
||||
return async function* streamData() {
|
||||
let text = '';
|
||||
const swipes = [];
|
||||
const state = { reasoning: '' };
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
if (value.data === '[DONE]') return;
|
||||
|
||||
tryParseStreamingError(response, value.data, { quiet: true });
|
||||
|
||||
let data = JSON.parse(value.data);
|
||||
|
||||
if (data?.choices?.[0]?.index > 0) {
|
||||
const swipeIndex = data.choices[0].index - 1;
|
||||
swipes[swipeIndex] = (swipes[swipeIndex] || '') + data.choices[0].text;
|
||||
} else {
|
||||
const newText = data?.choices?.[0]?.text || data?.content || '';
|
||||
text += newText;
|
||||
state.reasoning += data?.choices?.[0]?.reasoning ?? '';
|
||||
}
|
||||
|
||||
yield { text, swipes, state };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a formatted prompt string given an array of messages, a chosen instruct preset, and instruct settings.
|
||||
* @param {(ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt An array of messages
|
||||
* @param {InstructSettings|string} instructPreset Either the name of an instruct preset or the instruct preset object itself.
|
||||
* @param {Partial<InstructSettings>} instructSettings Optional instruct settings
|
||||
*/
|
||||
static constructPrompt(prompt, instructPreset, instructSettings) {
|
||||
// InstructPreset may either be a name or itself a preset
|
||||
if (typeof instructPreset === 'string') {
|
||||
const instructPresetManager = getPresetManager('instruct');
|
||||
instructPreset = instructPresetManager?.getCompletionPresetByName(instructPreset);
|
||||
}
|
||||
|
||||
// Clone the preset to avoid modifying the original
|
||||
instructPreset = structuredClone(instructPreset);
|
||||
if (instructSettings) { // apply any additional settings
|
||||
Object.assign(instructPreset, instructSettings);
|
||||
}
|
||||
|
||||
// Make the type check shut up. We 100% don't have a string here.
|
||||
if (typeof instructPreset === 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format messages using instruct formatting
|
||||
const formattedMessages = [];
|
||||
const prefillActive = prompt.length > 0 ? prompt[prompt.length - 1].role === 'assistant' : false;
|
||||
for (const message of prompt) {
|
||||
let messageContent = message.content;
|
||||
if (!message.ignoreInstruct) {
|
||||
const isLastMessage = message === prompt[prompt.length - 1];
|
||||
|
||||
// This complicated logic means:
|
||||
// 1. If prefill is not active, format all messages
|
||||
// 2. If prefill is active, format all messages except the last one
|
||||
if (!isLastMessage || !prefillActive) {
|
||||
messageContent = formatInstructModeChat(
|
||||
message.name ?? message.role,
|
||||
message.content,
|
||||
message.role === 'user',
|
||||
message.role === 'system',
|
||||
undefined,
|
||||
name1, // for macros
|
||||
name2, // for macros
|
||||
undefined,
|
||||
instructPreset,
|
||||
);
|
||||
}
|
||||
|
||||
// Add prompt formatting for the last message.
|
||||
// e.g. "<|im_start|>assistant"
|
||||
if (isLastMessage) {
|
||||
let last_line = formatInstructModePrompt(
|
||||
'assistant', // for sequences using {{name}}
|
||||
false, // not an impersonation
|
||||
prefillActive ? message.content : undefined, // if using prefill, last message is the prefill
|
||||
name1, // for macros
|
||||
name2, // for macros
|
||||
true, // quiet
|
||||
false,
|
||||
instructPreset,
|
||||
);
|
||||
|
||||
if (prefillActive) { // content is the prefilled message
|
||||
if (last_line.endsWith('\n') && !message.content.endsWith('\n')) {
|
||||
last_line = last_line.slice(0, -1); // remove newline after prefill if it's not in the prefill itself
|
||||
}
|
||||
messageContent = last_line;
|
||||
} else { // append last line to content (e.g. "<|im_start|>assistant:")
|
||||
messageContent += last_line;
|
||||
}
|
||||
}
|
||||
}
|
||||
formattedMessages.push(messageContent);
|
||||
}
|
||||
return formattedMessages.join('');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Process and send a text completion request with optional preset & instruct
|
||||
* @param {TextCompletionPayload} requestData
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string?} [options.presetName] - Name of the preset to use for generation settings
|
||||
* @param {string?} [options.instructName] - Name of instruct preset for message formatting
|
||||
* @param {Partial<InstructSettings>?} [options.instructSettings] - Override instruct settings
|
||||
* @param {boolean} extractData - Whether to extract structured data from response
|
||||
* @param {AbortSignal?} [signal]
|
||||
* @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
|
||||
* @throws {Error}
|
||||
*/
|
||||
static async processRequest(requestData, options = {}, extractData = true, signal = null) {
|
||||
const { presetName, instructName } = options;
|
||||
|
||||
// remove any undefined params in given request data
|
||||
requestData = this.createRequestData(requestData);
|
||||
|
||||
/** @type {InstructSettings | undefined} */
|
||||
let instructPreset;
|
||||
const prompt = requestData.prompt;
|
||||
// Handle instruct formatting if requested
|
||||
if (Array.isArray(prompt)) {
|
||||
if (instructName) {
|
||||
const instructPresetManager = getPresetManager('instruct');
|
||||
instructPreset = instructPresetManager?.getCompletionPresetByName(instructName);
|
||||
if (instructPreset) {
|
||||
requestData.prompt = this.constructPrompt(prompt, instructPreset, options.instructSettings);
|
||||
const stoppingStrings = getInstructStoppingSequences({ customInstruct: instructPreset, useStopStrings: false });
|
||||
requestData.stop = stoppingStrings;
|
||||
requestData.stopping_strings = stoppingStrings;
|
||||
} else {
|
||||
console.warn(`Instruct preset "${instructName}" not found, using basic formatting`);
|
||||
requestData.prompt = prompt.map(x => x.content).join('\n\n');
|
||||
}
|
||||
} else {
|
||||
requestData.prompt = prompt.map(x => x.content).join('\n\n');
|
||||
}
|
||||
} else if (typeof prompt === 'string') {
|
||||
requestData.prompt = prompt;
|
||||
}
|
||||
|
||||
// Apply generation preset if specified
|
||||
if (presetName) {
|
||||
const presetManager = getPresetManager(this.TYPE);
|
||||
if (presetManager) {
|
||||
const preset = presetManager.getCompletionPresetByName(presetName);
|
||||
if (preset) {
|
||||
// Convert preset to payload and merge with custom data
|
||||
requestData = this.presetToGeneratePayload(preset, {}, requestData);
|
||||
} else {
|
||||
console.warn(`Preset "${presetName}" not found, continuing with default settings`);
|
||||
}
|
||||
} else {
|
||||
console.warn('Preset manager not found, continuing with default settings');
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.sendRequest(requestData, extractData, signal);
|
||||
|
||||
// Remove stopping strings from the end
|
||||
if (!requestData.stream && extractData) {
|
||||
/** @type {ExtractedData} */
|
||||
// @ts-ignore
|
||||
const extractedData = response;
|
||||
|
||||
let message = extractedData.content;
|
||||
|
||||
message = message.replace(/[^\S\r\n]+$/gm, '');
|
||||
|
||||
if (requestData.stopping_strings) {
|
||||
for (const stoppingString of requestData.stopping_strings) {
|
||||
if (stoppingString.length) {
|
||||
for (let j = stoppingString.length; j > 0; j--) {
|
||||
if (message.slice(-j) === stoppingString.slice(0, j)) {
|
||||
message = message.slice(0, -j);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (instructPreset) {
|
||||
[
|
||||
instructPreset.stop_sequence,
|
||||
instructPreset.input_sequence,
|
||||
].forEach(sequence => {
|
||||
if (sequence?.trim()) {
|
||||
const index = message.indexOf(sequence);
|
||||
if (index !== -1) {
|
||||
message = message.substring(0, index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
[
|
||||
instructPreset.output_sequence,
|
||||
instructPreset.last_output_sequence,
|
||||
].forEach(sequences => {
|
||||
if (sequences) {
|
||||
sequences.split('\n')
|
||||
.filter(line => line.trim() !== '')
|
||||
.forEach(line => {
|
||||
message = message.replaceAll(line, '');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
extractedData.content = message;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a preset to a valid text completion payload.
|
||||
* Only supports temperature.
|
||||
* @param {Object} preset - The preset configuration
|
||||
* @param {Object} overridePreset - Additional parameters to override preset values
|
||||
* @param {Object} overridePayload - Additional parameters to override payload values
|
||||
* @returns {Object} - Formatted payload for text completion API
|
||||
*/
|
||||
static presetToGeneratePayload(preset, overridePreset = {}, overridePayload = {}) {
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
throw new Error('Invalid preset: must be an object');
|
||||
}
|
||||
|
||||
// apply preset overrides
|
||||
preset = { ...preset, ...overridePreset };
|
||||
|
||||
// Only take fields from the preset specified in setting_names to use as TextCompletionSettings
|
||||
const settings = structuredClone(textgenerationwebui_settings);
|
||||
for (const [key, value] of Object.entries(preset)) {
|
||||
if (!setting_names.includes(key)) continue;
|
||||
settings[key] = value;
|
||||
}
|
||||
|
||||
// convert to a generation payload
|
||||
const payload = createTextGenGenerationData(settings, overridePayload.model, overridePayload.prompt, preset.genamt);
|
||||
|
||||
// apply overrides
|
||||
return this.createRequestData({ ...payload, ...overridePayload });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates & sends a chat completion request.
|
||||
*/
|
||||
export class ChatCompletionService {
|
||||
static TYPE = 'openai';
|
||||
|
||||
/**
|
||||
* @param {ChatCompletionPayload} custom
|
||||
* @returns {ChatCompletionPayload}
|
||||
*/
|
||||
static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, reverse_proxy, proxy_password, custom_prompt_post_processing, ...props }) {
|
||||
const payload = {
|
||||
stream,
|
||||
messages,
|
||||
model,
|
||||
chat_completion_source,
|
||||
max_tokens,
|
||||
temperature,
|
||||
custom_url,
|
||||
reverse_proxy,
|
||||
proxy_password,
|
||||
custom_prompt_post_processing,
|
||||
use_sysprompt: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
// Remove undefined values to avoid API errors
|
||||
Object.keys(payload).forEach(key => {
|
||||
if (payload[key] === undefined) {
|
||||
delete payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a chat completion request
|
||||
* @param {ChatCompletionPayload} data Request data
|
||||
* @param {boolean?} extractData Extract message from the response. Default true
|
||||
* @param {AbortSignal?} signal Abort signal
|
||||
* @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
|
||||
* @throws {Error}
|
||||
*/
|
||||
static async sendRequest(data, extractData = true, signal = null) {
|
||||
const response = await fetch('/api/backends/chat-completions/generate', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
cache: 'no-cache',
|
||||
body: JSON.stringify(data),
|
||||
signal: signal ?? new AbortController().signal,
|
||||
});
|
||||
|
||||
if (!data.stream) {
|
||||
const json = await response.json();
|
||||
if (!response.ok || json.error) {
|
||||
throw new Error(String(json.error?.message || 'Response not OK'));
|
||||
}
|
||||
|
||||
if (!extractData) {
|
||||
return json;
|
||||
}
|
||||
|
||||
const result = {
|
||||
content: extractMessageFromData(json, this.TYPE),
|
||||
reasoning: extractReasoningFromData(json, {
|
||||
mainApi: this.TYPE,
|
||||
textGenType: data.chat_completion_source,
|
||||
ignoreShowThoughts: true,
|
||||
}),
|
||||
};
|
||||
// Try parse JSON
|
||||
if (data.json_schema) {
|
||||
result.content = JSON.parse(extractJsonFromData(json, { mainApi: this.TYPE, chatCompletionSource: data.chat_completion_source }));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
tryParseStreamingError(response, text, { quiet: true });
|
||||
|
||||
throw new Error(`Got response status ${response.status}`);
|
||||
}
|
||||
|
||||
const eventStream = new EventSourceStream();
|
||||
response.body.pipeThrough(eventStream);
|
||||
const reader = eventStream.readable.getReader();
|
||||
return async function* streamData() {
|
||||
let text = '';
|
||||
const swipes = [];
|
||||
const state = { reasoning: '', image: '' };
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
const rawData = value.data;
|
||||
if (rawData === '[DONE]') return;
|
||||
tryParseStreamingError(response, rawData, { quiet: true });
|
||||
const parsed = JSON.parse(rawData);
|
||||
|
||||
const reply = getStreamingReply(parsed, state, {
|
||||
chatCompletionSource: data.chat_completion_source,
|
||||
overrideShowThoughts: true,
|
||||
});
|
||||
if (Array.isArray(parsed?.choices) && parsed?.choices?.[0]?.index > 0) {
|
||||
const swipeIndex = parsed.choices[0].index - 1;
|
||||
swipes[swipeIndex] = (swipes[swipeIndex] || '') + reply;
|
||||
} else {
|
||||
text += reply;
|
||||
}
|
||||
|
||||
yield { text, swipes: swipes, state };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and send a chat completion request with optional preset
|
||||
* @param {ChatCompletionPayload} requestData - payload data, overriding preset if given
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string?} [options.presetName] - Name of the preset to use for generation settings
|
||||
* @param {boolean} [extractData=true] - Whether to extract structured data from response
|
||||
* @param {AbortSignal?} [signal] - Abort signal
|
||||
* @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
|
||||
* @throws {Error}
|
||||
*/
|
||||
static async processRequest(requestData, options, extractData = true, signal = null) {
|
||||
const { presetName } = options;
|
||||
requestData = this.createRequestData(requestData);
|
||||
|
||||
// Apply generation preset if specified
|
||||
if (presetName) {
|
||||
const presetManager = getPresetManager(this.TYPE);
|
||||
if (presetManager) {
|
||||
const preset = presetManager.getCompletionPresetByName(presetName);
|
||||
if (preset) {
|
||||
// Convert preset to payload and merge with custom parameters
|
||||
requestData = await this.presetToGeneratePayload(preset, {}, requestData);
|
||||
} else {
|
||||
console.warn(`Preset "${presetName}" not found, continuing with default settings`);
|
||||
}
|
||||
} else {
|
||||
console.warn('Preset manager not found, continuing with default settings');
|
||||
}
|
||||
}
|
||||
|
||||
return await this.sendRequest(requestData, extractData, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a preset to a valid chat completion payload
|
||||
* Only supports temperature.
|
||||
* @param {Object} preset - The preset configuration
|
||||
* @param {Object} overridePreset - Additional parameters to override preset values
|
||||
* @param {Object} overridePayload - Additional parameters to override payload values
|
||||
* @returns {Promise<any>} - Formatted payload for chat completion API
|
||||
*/
|
||||
static async presetToGeneratePayload(preset, overridePreset = {}, overridePayload = {}) {
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
throw new Error('Invalid preset: must be an object');
|
||||
}
|
||||
|
||||
// apply preset overrides
|
||||
preset = { ...preset, ...overridePreset };
|
||||
|
||||
// Fix any fields before converting to settings
|
||||
preset.bias_preset_selected = preset.bias_presets !== undefined ? preset.bias_preset_selected : undefined; // presets might have bias_preset_selected but not bias_presets, but settings need both or neither.
|
||||
|
||||
// Convert from preset to ChatCompletionSettings
|
||||
const settings = structuredClone(oai_settings);
|
||||
for (const [key, value] of Object.entries(preset)) {
|
||||
const settingToUpdate = settingsToUpdate[key];
|
||||
if (!settingToUpdate) continue;
|
||||
settings[settingToUpdate[1]] = value;
|
||||
}
|
||||
|
||||
// Ensure api-url is properly applied for all sources that accept it
|
||||
['custom_url', 'vertexai_region', 'zai_endpoint'].forEach(field => {
|
||||
// The order is: connection profile => CC preset => CC settings
|
||||
overridePayload[field] = overridePayload[field] || settings[field] || oai_settings[field];
|
||||
});
|
||||
|
||||
// Convert from settings to generation payload
|
||||
const data = await createGenerationParameters(settings, overridePayload.model, 'quiet', overridePayload.messages);
|
||||
const payload = data.generate_data;
|
||||
|
||||
// apply overrides
|
||||
return this.createRequestData({ ...payload, ...overridePayload });
|
||||
}
|
||||
}
|
||||
405
web-app/public/scripts/data-maid.js
Normal file
405
web-app/public/scripts/data-maid.js
Normal file
@@ -0,0 +1,405 @@
|
||||
import { getRequestHeaders } from '../script.js';
|
||||
import { VIDEO_EXTENSIONS } from './constants.js';
|
||||
import { t } from './i18n.js';
|
||||
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
|
||||
import { renderTemplateAsync } from './templates.js';
|
||||
import { humanFileSize, timestampToMoment } from './utils.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} DataMaidReportResult
|
||||
* @property {import('../../src/endpoints/data-maid.js').DataMaidSanitizedReport} report - The sanitized report of the Data Maid.
|
||||
* @property {string} token - The token to use for the Data Maid report.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data Maid Dialog class for managing the cleanup dialog interface.
|
||||
*/
|
||||
class DataMaidDialog {
|
||||
constructor() {
|
||||
this.token = null;
|
||||
this.container = null;
|
||||
this.isScanning = false;
|
||||
|
||||
this.DATA_MAID_CATEGORIES = {
|
||||
files: {
|
||||
name: t`Files`,
|
||||
description: t`Files that are not associated with chat messages or Data Bank. WILL DELETE MANUAL UPLOADS!`,
|
||||
},
|
||||
images: {
|
||||
name: t`Images`,
|
||||
description: t`Images that are not associated with chat messages. WILL DELETE MANUAL UPLOADS!`,
|
||||
},
|
||||
chats: {
|
||||
name: t`Chats`,
|
||||
description: t`Chat files associated with deleted characters.`,
|
||||
},
|
||||
groupChats: {
|
||||
name: t`Group Chats`,
|
||||
description: t`Chat files associated with deleted groups.`,
|
||||
},
|
||||
avatarThumbnails: {
|
||||
name: t`Avatar Thumbnails`,
|
||||
description: t`Thumbnails for avatars of missing or deleted characters.`,
|
||||
},
|
||||
backgroundThumbnails: {
|
||||
name: t`Background Thumbnails`,
|
||||
description: t`Thumbnails for missing or deleted backgrounds.`,
|
||||
},
|
||||
personaThumbnails: {
|
||||
name: t`Persona Thumbnails`,
|
||||
description: t`Thumbnails for missing or deleted personas.`,
|
||||
},
|
||||
chatBackups: {
|
||||
name: t`Chat Backups`,
|
||||
description: t`Automatically generated chat backups.`,
|
||||
},
|
||||
settingsBackups: {
|
||||
name: t`Settings Backups`,
|
||||
description: t`Automatically generated settings backups.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to the Data Maid report.
|
||||
* @returns {Promise<DataMaidReportResult>}
|
||||
* @private
|
||||
*/
|
||||
async getReport() {
|
||||
const response = await fetch('/api/data-maid/report', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders({ omitContentType: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching Data Maid report: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes the Data Maid process by sending a request to the server.
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async finalize() {
|
||||
const response = await fetch('/api/data-maid/finalize', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ token: this.token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error finalizing Data Maid: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the dialog UI elements and event listeners.
|
||||
* @private
|
||||
*/
|
||||
async setupDialogUI() {
|
||||
const template = await renderTemplateAsync('dataMaidDialog');
|
||||
this.container = document.createElement('div');
|
||||
this.container.classList.add('dataMaidDialogContainer');
|
||||
this.container.innerHTML = template;
|
||||
|
||||
const startButton = this.container.querySelector('.dataMaidStartButton');
|
||||
startButton.addEventListener('click', () => this.handleScanClick());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the scan button click event.
|
||||
* @private
|
||||
*/
|
||||
async handleScanClick() {
|
||||
if (this.isScanning) {
|
||||
toastr.warning(t`The scan is already running. Please wait for it to finish.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resultsList = this.container.querySelector('.dataMaidResultsList');
|
||||
resultsList.innerHTML = '';
|
||||
this.showSpinner();
|
||||
this.isScanning = true;
|
||||
|
||||
const report = await this.getReport();
|
||||
|
||||
this.hideSpinner();
|
||||
await this.renderReport(report, resultsList);
|
||||
this.token = report.token;
|
||||
} catch (error) {
|
||||
this.hideSpinner();
|
||||
toastr.error(t`An error has occurred. Check the console for details.`);
|
||||
console.error('Error generating Data Maid report:', error);
|
||||
} finally {
|
||||
this.isScanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the loading spinner and hides the placeholder.
|
||||
* @private
|
||||
*/
|
||||
showSpinner() {
|
||||
const spinner = this.container.querySelector('.dataMaidSpinner');
|
||||
const placeholder = this.container.querySelector('.dataMaidPlaceholder');
|
||||
placeholder.classList.add('displayNone');
|
||||
spinner.classList.remove('displayNone');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the loading spinner.
|
||||
* @private
|
||||
*/
|
||||
hideSpinner() {
|
||||
const spinner = this.container.querySelector('.dataMaidSpinner');
|
||||
spinner.classList.add('displayNone');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Data Maid report into the results list.
|
||||
* @param {DataMaidReportResult} report
|
||||
* @param {Element} resultsList
|
||||
* @private
|
||||
*/
|
||||
async renderReport(report, resultsList) {
|
||||
for (const [prop, data] of Object.entries(this.DATA_MAID_CATEGORIES)) {
|
||||
const category = await this.renderCategory(prop, data.name, data.description, report.report[prop]);
|
||||
if (!category) {
|
||||
continue;
|
||||
}
|
||||
resultsList.appendChild(category);
|
||||
}
|
||||
this.displayEmptyPlaceholder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a placeholder message if no items are found in the results list.
|
||||
* @private
|
||||
*/
|
||||
displayEmptyPlaceholder() {
|
||||
const resultsList = this.container.querySelector('.dataMaidResultsList');
|
||||
if (resultsList.children.length === 0) {
|
||||
const placeholder = this.container.querySelector('.dataMaidPlaceholder');
|
||||
placeholder.classList.remove('displayNone');
|
||||
placeholder.textContent = t`No items found to clean up. Come back later!`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single Data Maid category into a DOM element.
|
||||
* @param {string} prop Property name for the category
|
||||
* @param {string} name Name of the category
|
||||
* @param {string} description Description of the category
|
||||
* @param {import('../../src/endpoints/data-maid.js').DataMaidSanitizedRecord[]} items List of items in the category
|
||||
* @return {Promise<Element|null>} A promise that resolves to a DOM element containing the rendered category
|
||||
* @private
|
||||
*/
|
||||
async renderCategory(prop, name, description, items) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const viewModel = {
|
||||
name: name,
|
||||
description: description,
|
||||
totalSize: humanFileSize(items.reduce((sum, item) => sum + item.size, 0)),
|
||||
totalItems: items.length,
|
||||
items: items.sort((a, b) => b.mtime - a.mtime).map(item => ({
|
||||
...item,
|
||||
size: humanFileSize(item.size),
|
||||
date: timestampToMoment(item.mtime).format('L LT'),
|
||||
})),
|
||||
};
|
||||
|
||||
const template = await renderTemplateAsync('dataMaidCategory', viewModel);
|
||||
const categoryElement = document.createElement('div');
|
||||
categoryElement.innerHTML = template;
|
||||
categoryElement.querySelectorAll('.dataMaidItemView').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = button.closest('.dataMaidItem');
|
||||
const hash = item?.getAttribute('data-hash');
|
||||
const itemName = items.find(i => i.hash === hash)?.name;
|
||||
if (hash) {
|
||||
await this.view(prop, hash, itemName);
|
||||
}
|
||||
});
|
||||
});
|
||||
categoryElement.querySelectorAll('.dataMaidItemDownload').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = button.closest('.dataMaidItem');
|
||||
const hash = item?.getAttribute('data-hash');
|
||||
if (hash) {
|
||||
await this.download(items, hash);
|
||||
}
|
||||
});
|
||||
});
|
||||
categoryElement.querySelectorAll('.dataMaidDeleteAll').forEach(button => {
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
const confirm = await Popup.show.confirm(t`Are you sure?`, t`This will permanently delete all files in this category. THIS CANNOT BE UNDONE!`);
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hashes = items.map(item => item.hash).filter(hash => hash);
|
||||
await this.delete(hashes);
|
||||
|
||||
categoryElement.remove();
|
||||
this.displayEmptyPlaceholder();
|
||||
});
|
||||
|
||||
});
|
||||
categoryElement.querySelectorAll('.dataMaidItemDelete').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = button.closest('.dataMaidItem');
|
||||
const hash = item?.getAttribute('data-hash');
|
||||
if (hash) {
|
||||
const confirm = await Popup.show.confirm(t`Are you sure?`, t`This will permanently delete the file. THIS CANNOT BE UNDONE!`);
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
if (await this.delete([hash])) {
|
||||
item.remove();
|
||||
items.splice(items.findIndex(i => i.hash === hash), 1);
|
||||
if (items.length === 0) {
|
||||
categoryElement.remove();
|
||||
this.displayEmptyPlaceholder();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return categoryElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the URL for viewing an item by its hash.
|
||||
* @param {string} hash Hash of the item to view
|
||||
* @returns {string} URL to view the item
|
||||
* @private
|
||||
*/
|
||||
getViewUrl(hash) {
|
||||
return `/api/data-maid/view?hash=${encodeURIComponent(hash)}&token=${encodeURIComponent(this.token)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an item by its hash.
|
||||
* @param {import('../../src/endpoints/data-maid.js').DataMaidSanitizedRecord[]} items List of items in the category
|
||||
* @param {string} hash Hash of the item to download
|
||||
* @private
|
||||
*/
|
||||
async download(items, hash) {
|
||||
const item = items.find(i => i.hash === hash);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const url = this.getViewUrl(hash);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = item?.name || hash;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the item view for a specific hash.
|
||||
* @param {string} prop Property name for the category
|
||||
* @param {string} hash Item hash to view
|
||||
* @param {string} name Name of the item to view
|
||||
* @private
|
||||
*/
|
||||
async view(prop, hash, name) {
|
||||
const url = this.getViewUrl(hash);
|
||||
const isImage = ['images', 'avatarThumbnails', 'backgroundThumbnails'].includes(prop);
|
||||
const element = isImage
|
||||
? await this.getViewElement(url, name)
|
||||
: await this.getTextViewElement(url);
|
||||
await callGenericPopup(element, POPUP_TYPE.DISPLAY, '', { large: true, wide: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an item by its file path hash.
|
||||
* @param {string[]} hashes Hashes of items to delete
|
||||
* @return {Promise<boolean>} True if the deletion was successful, false otherwise
|
||||
* @private
|
||||
*/
|
||||
async delete(hashes) {
|
||||
try {
|
||||
const response = await fetch('/api/data-maid/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ hashes: hashes, token: this.token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error deleting item: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a media element for viewing images or videos.
|
||||
* @param {string} url View URL
|
||||
* @param {string} name Name of the file
|
||||
* @returns {Promise<HTMLElement>} Image element
|
||||
* @private
|
||||
*/
|
||||
async getViewElement(url, name) {
|
||||
const isVideo = VIDEO_EXTENSIONS.includes(name.split('.').pop());
|
||||
const mediaElement = document.createElement(isVideo ? 'video' : 'img');
|
||||
if (mediaElement instanceof HTMLVideoElement) {
|
||||
mediaElement.controls = true;
|
||||
}
|
||||
mediaElement.src = url;
|
||||
mediaElement.classList.add('dataMaidImageView');
|
||||
return mediaElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an iframe element for viewing text content.
|
||||
* @param {string} url View URL
|
||||
* @returns {Promise<HTMLTextAreaElement>} Frame element
|
||||
* @private
|
||||
*/
|
||||
async getTextViewElement(url) {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
const element = document.createElement('textarea');
|
||||
element.classList.add('dataMaidTextView');
|
||||
element.readOnly = true;
|
||||
element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Data Maid dialog and handles the interaction.
|
||||
*/
|
||||
async open() {
|
||||
await this.setupDialogUI();
|
||||
await callGenericPopup(this.container, POPUP_TYPE.TEXT, '', { wide: true, large: true });
|
||||
|
||||
if (this.token) {
|
||||
await this.finalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initDataMaid() {
|
||||
const dataMaidButton = document.getElementById('data_maid_button');
|
||||
if (!dataMaidButton) {
|
||||
console.warn('Data Maid button not found');
|
||||
return;
|
||||
}
|
||||
|
||||
dataMaidButton.addEventListener('click', () => new DataMaidDialog().open());
|
||||
}
|
||||
66
web-app/public/scripts/dom-handlers.js
Normal file
66
web-app/public/scripts/dom-handlers.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { throttle } from './utils.js';
|
||||
|
||||
export function initDomHandlers() {
|
||||
handleInputWheel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trap mouse wheel inside of focused number inputs to prevent scrolling their containers.
|
||||
* Instead of firing wheel events, manually update both slider and input values.
|
||||
* This also makes wheel work inside Firefox.
|
||||
*/
|
||||
function handleInputWheel() {
|
||||
const minInterval = 25; // ms
|
||||
|
||||
/**
|
||||
* Update input and slider values based on wheel delta
|
||||
* @param {HTMLInputElement} input The number input element
|
||||
* @param {HTMLInputElement|null} slider The associated range input element, if any
|
||||
* @param {number} deltaY The wheel deltaY value
|
||||
*/
|
||||
function updateValue(input, slider, deltaY) {
|
||||
const currentValue = parseFloat(input.value);
|
||||
const step = parseFloat(input.step);
|
||||
const min = parseFloat(input.min);
|
||||
const max = parseFloat(input.max);
|
||||
|
||||
// Sanity checks before trying to calculate new value
|
||||
if (isNaN(currentValue) || isNaN(step) || step <= 0 || deltaY === 0) return;
|
||||
|
||||
// Calculate new value based on wheel movement delta (negative = up, positive = down)
|
||||
let newValue = currentValue + (deltaY > 0 ? -step : step);
|
||||
// Ensure it's a multiple of step
|
||||
newValue = Math.round(newValue / step) * step;
|
||||
// Ensure it's within the min and max range (NaN-aware)
|
||||
newValue = !isNaN(min) ? Math.max(newValue, min) : newValue;
|
||||
newValue = !isNaN(max) ? Math.min(newValue, max) : newValue;
|
||||
// Simple fix for floating point precision issues
|
||||
newValue = Math.round(newValue * 1e10) / 1e10;
|
||||
|
||||
// Update both input and slider values
|
||||
input.value = newValue.toString();
|
||||
if (slider) slider.value = newValue.toString();
|
||||
// Trigger input event (just ONE) to update any listeners
|
||||
const inputEvent = new Event('input', { bubbles: true });
|
||||
input.dispatchEvent(inputEvent);
|
||||
}
|
||||
|
||||
const updateValueThrottled = throttle(updateValue, minInterval);
|
||||
|
||||
document.addEventListener('wheel', (e) => {
|
||||
// Try to carefully narrow down if we even need to fire this handler
|
||||
const input = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
|
||||
if (input && input.type === 'number' && input.hasAttribute('step')) {
|
||||
const parent = input.closest('.range-block-range-and-counter') ?? input.closest('div') ?? input.parentElement;
|
||||
const slider = /** @type {HTMLInputElement} */ (parent?.querySelector('input[type="range"]'));
|
||||
|
||||
// Stop propagation for either target
|
||||
if (e.target === input || (slider && e.target === slider)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
updateValueThrottled(input, slider, e.deltaY);
|
||||
}
|
||||
}
|
||||
}, { passive: false });
|
||||
}
|
||||
107
web-app/public/scripts/dragdrop.js
vendored
Normal file
107
web-app/public/scripts/dragdrop.js
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
import { debounce_timeout } from './constants.js';
|
||||
|
||||
/**
|
||||
* Drag and drop handler
|
||||
*
|
||||
* Can be used on any element, enabling drag&drop styling and callback on drop.
|
||||
*/
|
||||
export class DragAndDropHandler {
|
||||
/** @private @type {JQuery.Selector} */ selector;
|
||||
/** @private @type {(files: File[], event:JQuery.DropEvent<HTMLElement, undefined, any, any>) => void} */ onDropCallback;
|
||||
/** @private @type {NodeJS.Timeout} Remark: Not actually NodeJS timeout, but it's close */ dragLeaveTimeout;
|
||||
|
||||
/** @private @type {boolean} */ noAnimation;
|
||||
|
||||
/**
|
||||
* Create a DragAndDropHandler
|
||||
* @param {JQuery.Selector} selector - The CSS selector for the elements to enable drag and drop
|
||||
* @param {(files: File[], event:JQuery.DropEvent<HTMLElement, undefined, any, any>) => void} onDropCallback - The callback function to handle the drop event
|
||||
*/
|
||||
constructor(selector, onDropCallback, { noAnimation = false } = {}) {
|
||||
this.selector = selector;
|
||||
this.onDropCallback = onDropCallback;
|
||||
this.dragLeaveTimeout = null;
|
||||
|
||||
this.noAnimation = noAnimation;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the drag and drop functionality
|
||||
*/
|
||||
destroy() {
|
||||
if (this.selector === 'body') {
|
||||
$(document.body).off('dragover', this.handleDragOver.bind(this));
|
||||
$(document.body).off('dragleave', this.handleDragLeave.bind(this));
|
||||
$(document.body).off('drop', this.handleDrop.bind(this));
|
||||
} else {
|
||||
$(document.body).off('dragover', this.selector, this.handleDragOver.bind(this));
|
||||
$(document.body).off('dragleave', this.selector, this.handleDragLeave.bind(this));
|
||||
$(document.body).off('drop', this.selector, this.handleDrop.bind(this));
|
||||
}
|
||||
|
||||
$(this.selector).remove('drop_target no_animation');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the drag and drop functionality
|
||||
* Automatically called on construction
|
||||
* @private
|
||||
*/
|
||||
init() {
|
||||
if (this.selector === 'body') {
|
||||
$(document.body).on('dragover', this.handleDragOver.bind(this));
|
||||
$(document.body).on('dragleave', this.handleDragLeave.bind(this));
|
||||
$(document.body).on('drop', this.handleDrop.bind(this));
|
||||
} else {
|
||||
$(document.body).on('dragover', this.selector, this.handleDragOver.bind(this));
|
||||
$(document.body).on('dragleave', this.selector, this.handleDragLeave.bind(this));
|
||||
$(document.body).on('drop', this.selector, this.handleDrop.bind(this));
|
||||
}
|
||||
|
||||
$(this.selector).addClass('drop_target');
|
||||
if (this.noAnimation) $(this.selector).addClass('no_animation');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {JQuery.DragOverEvent<HTMLElement, undefined, any, any>} event - The dragover event
|
||||
* @private
|
||||
*/
|
||||
handleDragOver(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearTimeout(this.dragLeaveTimeout);
|
||||
$(this.selector).addClass('drop_target dragover');
|
||||
if (this.noAnimation) $(this.selector).addClass('no_animation');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {JQuery.DragLeaveEvent<HTMLElement, undefined, any, any>} event - The dragleave event
|
||||
* @private
|
||||
*/
|
||||
handleDragLeave(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Debounce the removal of the class, so it doesn't "flicker" on dragging over
|
||||
clearTimeout(this.dragLeaveTimeout);
|
||||
this.dragLeaveTimeout = setTimeout(() => {
|
||||
$(this.selector).removeClass('dragover');
|
||||
}, debounce_timeout.quick);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {JQuery.DropEvent<HTMLElement, undefined, any, any>} event - The drop event
|
||||
* @private
|
||||
*/
|
||||
handleDrop(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearTimeout(this.dragLeaveTimeout);
|
||||
$(this.selector).removeClass('dragover');
|
||||
|
||||
const files = Array.from(event.originalEvent.dataTransfer.files);
|
||||
this.onDropCallback(files, event);
|
||||
}
|
||||
}
|
||||
198
web-app/public/scripts/dynamic-styles.js
Normal file
198
web-app/public/scripts/dynamic-styles.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/** @type {CSSStyleSheet} */
|
||||
let dynamicStyleSheet = null;
|
||||
/** @type {CSSStyleSheet} */
|
||||
let dynamicExtensionStyleSheet = null;
|
||||
|
||||
/**
|
||||
* An observer that will check if any new stylesheets are added to the head
|
||||
* @type {MutationObserver}
|
||||
*/
|
||||
const observer = new MutationObserver(mutations => {
|
||||
mutations.forEach(mutation => {
|
||||
if (mutation.type !== 'childList') return;
|
||||
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (node instanceof HTMLLinkElement && node.tagName === 'LINK' && node.rel === 'stylesheet') {
|
||||
node.addEventListener('load', () => {
|
||||
try {
|
||||
applyDynamicFocusStyles(node.sheet);
|
||||
} catch (e) {
|
||||
console.warn('Failed to process new stylesheet:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Generates dynamic focus styles based on the given stylesheet, taking its hover styles as reference
|
||||
*
|
||||
* @param {CSSStyleSheet} styleSheet - The stylesheet to process
|
||||
* @param {object} [options] - Optional configuration options
|
||||
* @param {boolean} [options.fromExtension=false] - Indicates if the styles are from an extension
|
||||
*/
|
||||
function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
|
||||
/** @typedef {{ type: 'media'|'supports'|'container', conditionText: string }} WrapperCond */
|
||||
/** @type {{baseSelector: string, rule: CSSStyleRule, wrappers: WrapperCond[]}[]} */
|
||||
const hoverRules = [];
|
||||
/** @type {Set<string>} */
|
||||
const focusRules = new Set();
|
||||
|
||||
const PLACEHOLDER = ':__PLACEHOLDER__';
|
||||
|
||||
/**
|
||||
* Builds a stable signature string for a chain of wrapper conditions so we can distinguish
|
||||
* identical selectors under different contexts (e.g., different @media queries)
|
||||
* @param {WrapperCond[]} wrappers
|
||||
* @returns {string}
|
||||
*/
|
||||
function wrapperSignature(wrappers) {
|
||||
return wrappers.map(w => `${w.type}:${w.conditionText}`).join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the CSS rules and separates selectors for hover and focus
|
||||
* @param {CSSRuleList} rules - The CSS rules to process
|
||||
* @param {WrapperCond[]} wrappers - Current chain of wrapper conditions (@media/@supports/etc.)
|
||||
*/
|
||||
function processRules(rules, wrappers = []) {
|
||||
Array.from(rules).forEach(rule => {
|
||||
if (rule instanceof CSSImportRule) {
|
||||
// Make sure that @import rules are processed recursively
|
||||
// If the @import has media conditions, treat them as wrappers as well
|
||||
/** @type {WrapperCond[]} */
|
||||
const extra = (rule.media && rule.media.mediaText) ? [{ type: 'media', conditionText: rule.media.mediaText }] : [];
|
||||
processImportedStylesheet(rule.styleSheet, [...wrappers, ...extra]);
|
||||
} else if (rule instanceof CSSStyleRule) {
|
||||
// Separate multiple selectors on a rule
|
||||
const selectors = rule.selectorText.split(',').map(s => s.trim());
|
||||
|
||||
// We collect all hover and focus rules to be able to later decide which hover rules don't have a matching focus rule
|
||||
selectors.forEach(selector => {
|
||||
const isHover = selector.includes(':hover'), isFocus = selector.includes(':focus');
|
||||
if (isHover && isFocus) {
|
||||
// We currently do nothing here. Rules containing both hover and focus are very specific and should never be automatically touched
|
||||
}
|
||||
else if (isHover) {
|
||||
const baseSelector = selector.replace(/:hover/g, PLACEHOLDER).trim();
|
||||
hoverRules.push({ baseSelector, rule, wrappers: [...wrappers] });
|
||||
} else if (isFocus) {
|
||||
// We need to make sure that we remember all existing :focus, :focus-within and :focus-visible rules
|
||||
const baseSelector = selector.replace(/:focus(-within|-visible)?/g, PLACEHOLDER).trim();
|
||||
focusRules.add(`${baseSelector}|${wrapperSignature(wrappers)}`);
|
||||
}
|
||||
});
|
||||
} else if (rule instanceof CSSMediaRule) {
|
||||
// Recursively process nested @media rules
|
||||
processRules(rule.cssRules, [...wrappers, { type: 'media', conditionText: rule.conditionText }]);
|
||||
} else if (rule instanceof CSSSupportsRule) {
|
||||
// Recursively process nested @supports rules
|
||||
processRules(rule.cssRules, [...wrappers, { type: 'supports', conditionText: rule.conditionText }]);
|
||||
} else if (rule instanceof window.CSSContainerRule) {
|
||||
// Recursively process nested @container rules (if supported by the browser)
|
||||
// Note: conditionText contains the query like "(min-width: 300px)" or "style(color)"
|
||||
// Using 'container' as the type ensures uniqueness separate from @media/@supports
|
||||
processRules(rule.cssRules, [...wrappers, { type: 'container', conditionText: rule.conditionText }]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the CSS rules of an imported stylesheet recursively
|
||||
* @param {CSSStyleSheet} sheet - The imported stylesheet to process
|
||||
* @param {WrapperCond[]} wrappers - Wrapper conditions inherited from (at)import media
|
||||
*/
|
||||
function processImportedStylesheet(sheet, wrappers = []) {
|
||||
if (sheet && sheet.cssRules) {
|
||||
processRules(sheet.cssRules, wrappers);
|
||||
}
|
||||
}
|
||||
|
||||
processRules(styleSheet.cssRules, []);
|
||||
|
||||
/** @type {CSSStyleSheet} */
|
||||
let targetStyleSheet = null;
|
||||
|
||||
// Now finally create the dynamic focus rules
|
||||
hoverRules.forEach(({ baseSelector, rule, wrappers }) => {
|
||||
if (!focusRules.has(`${baseSelector}|${wrapperSignature(wrappers)}`)) {
|
||||
// Only initialize the dynamic stylesheet if needed
|
||||
targetStyleSheet ??= getDynamicStyleSheet({ fromExtension });
|
||||
|
||||
// The closest keyboard-equivalent to :hover styling is utilizing the :focus-visible rule from modern browsers.
|
||||
// It let's the browser decide whether a focus highlighting is expected and makes sense.
|
||||
// So we take all :hover rules that don't have a manually defined focus rule yet, and create their
|
||||
// :focus-visible counterpart, which will make the styling work the same for keyboard and mouse.
|
||||
// If something like :focus-within or a more specific selector like `.blah:has(:focus-visible)` for elements inside,
|
||||
// it should be manually defined in CSS.
|
||||
const focusSelector = rule.selectorText.replace(/:hover/g, ':focus-visible');
|
||||
let focusRule = `${focusSelector} { ${rule.style.cssText} }`;
|
||||
|
||||
// Wrap the generated rule into the same @media/@supports/@container chain (if any)
|
||||
if (wrappers.length > 0) {
|
||||
// Build nested blocks from outermost to innermost
|
||||
// Example: @media (x) { @supports (y) { <rule> } }
|
||||
focusRule = wrappers.reduceRight((inner, w) => {
|
||||
if (w.type === 'media') return `@media ${w.conditionText} { ${inner} }`;
|
||||
if (w.type === 'supports') return `@supports ${w.conditionText} { ${inner} }`;
|
||||
if (w.type === 'container') return `@container ${w.conditionText} { ${inner} }`;
|
||||
return inner;
|
||||
}, focusRule);
|
||||
}
|
||||
|
||||
try {
|
||||
targetStyleSheet.insertRule(focusRule, targetStyleSheet.cssRules.length);
|
||||
} catch (e) {
|
||||
console.warn('Failed to insert focus rule:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the stylesheet that should be used for dynamic rules
|
||||
*
|
||||
* @param {object} options - The options object
|
||||
* @param {boolean} [options.fromExtension=false] - Indicates whether the rules are coming from extensions
|
||||
* @return {CSSStyleSheet} The dynamic stylesheet
|
||||
*/
|
||||
function getDynamicStyleSheet({ fromExtension = false } = {}) {
|
||||
if (fromExtension) {
|
||||
if (!dynamicExtensionStyleSheet) {
|
||||
const styleSheetElement = document.createElement('style');
|
||||
styleSheetElement.setAttribute('id', 'dynamic-extension-styles');
|
||||
document.head.appendChild(styleSheetElement);
|
||||
dynamicExtensionStyleSheet = styleSheetElement.sheet;
|
||||
}
|
||||
return dynamicExtensionStyleSheet;
|
||||
} else {
|
||||
if (!dynamicStyleSheet) {
|
||||
const styleSheetElement = document.createElement('style');
|
||||
styleSheetElement.setAttribute('id', 'dynamic-styles');
|
||||
document.head.appendChild(styleSheetElement);
|
||||
dynamicStyleSheet = styleSheetElement.sheet;
|
||||
}
|
||||
return dynamicStyleSheet;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes dynamic styles for ST
|
||||
*/
|
||||
export function initDynamicStyles() {
|
||||
// Start observing the head for any new added stylesheets
|
||||
observer.observe(document.head, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
// Process all stylesheets on initial load
|
||||
Array.from(document.styleSheets).forEach(sheet => {
|
||||
try {
|
||||
applyDynamicFocusStyles(sheet, { fromExtension: sheet.href?.toLowerCase().includes('scripts/extensions') == true });
|
||||
} catch (e) {
|
||||
console.warn('Failed to process stylesheet on initial load:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
98
web-app/public/scripts/events.js
Normal file
98
web-app/public/scripts/events.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { EventEmitter } from '../lib/eventemitter.js';
|
||||
|
||||
export const event_types = {
|
||||
APP_READY: 'app_ready',
|
||||
EXTRAS_CONNECTED: 'extras_connected',
|
||||
MESSAGE_SWIPED: 'message_swiped',
|
||||
MESSAGE_SENT: 'message_sent',
|
||||
MESSAGE_RECEIVED: 'message_received',
|
||||
MESSAGE_EDITED: 'message_edited',
|
||||
MESSAGE_DELETED: 'message_deleted',
|
||||
MESSAGE_UPDATED: 'message_updated',
|
||||
MESSAGE_FILE_EMBEDDED: 'message_file_embedded',
|
||||
MESSAGE_REASONING_EDITED: 'message_reasoning_edited',
|
||||
MESSAGE_REASONING_DELETED: 'message_reasoning_deleted',
|
||||
MESSAGE_SWIPE_DELETED: 'message_swipe_deleted',
|
||||
MORE_MESSAGES_LOADED: 'more_messages_loaded',
|
||||
IMPERSONATE_READY: 'impersonate_ready',
|
||||
CHAT_CHANGED: 'chat_id_changed',
|
||||
GENERATION_AFTER_COMMANDS: 'GENERATION_AFTER_COMMANDS',
|
||||
GENERATION_STARTED: 'generation_started',
|
||||
GENERATION_STOPPED: 'generation_stopped',
|
||||
GENERATION_ENDED: 'generation_ended',
|
||||
SD_PROMPT_PROCESSING: 'sd_prompt_processing',
|
||||
EXTENSIONS_FIRST_LOAD: 'extensions_first_load',
|
||||
EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded',
|
||||
SETTINGS_LOADED: 'settings_loaded',
|
||||
SETTINGS_UPDATED: 'settings_updated',
|
||||
GROUP_UPDATED: 'group_updated',
|
||||
MOVABLE_PANELS_RESET: 'movable_panels_reset',
|
||||
SETTINGS_LOADED_BEFORE: 'settings_loaded_before',
|
||||
SETTINGS_LOADED_AFTER: 'settings_loaded_after',
|
||||
CHATCOMPLETION_SOURCE_CHANGED: 'chatcompletion_source_changed',
|
||||
CHATCOMPLETION_MODEL_CHANGED: 'chatcompletion_model_changed',
|
||||
OAI_PRESET_CHANGED_BEFORE: 'oai_preset_changed_before',
|
||||
OAI_PRESET_CHANGED_AFTER: 'oai_preset_changed_after',
|
||||
OAI_PRESET_EXPORT_READY: 'oai_preset_export_ready',
|
||||
OAI_PRESET_IMPORT_READY: 'oai_preset_import_ready',
|
||||
WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',
|
||||
WORLDINFO_UPDATED: 'worldinfo_updated',
|
||||
CHARACTER_EDITOR_OPENED: 'character_editor_opened',
|
||||
CHARACTER_EDITED: 'character_edited',
|
||||
CHARACTER_PAGE_LOADED: 'character_page_loaded',
|
||||
CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE: 'character_group_overlay_state_change_before',
|
||||
CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER: 'character_group_overlay_state_change_after',
|
||||
USER_MESSAGE_RENDERED: 'user_message_rendered',
|
||||
CHARACTER_MESSAGE_RENDERED: 'character_message_rendered',
|
||||
FORCE_SET_BACKGROUND: 'force_set_background',
|
||||
CHAT_DELETED: 'chat_deleted',
|
||||
CHAT_CREATED: 'chat_created',
|
||||
GROUP_CHAT_DELETED: 'group_chat_deleted',
|
||||
GROUP_CHAT_CREATED: 'group_chat_created',
|
||||
GENERATE_BEFORE_COMBINE_PROMPTS: 'generate_before_combine_prompts',
|
||||
GENERATE_AFTER_COMBINE_PROMPTS: 'generate_after_combine_prompts',
|
||||
GENERATE_AFTER_DATA: 'generate_after_data',
|
||||
GROUP_MEMBER_DRAFTED: 'group_member_drafted',
|
||||
GROUP_WRAPPER_STARTED: 'group_wrapper_started',
|
||||
GROUP_WRAPPER_FINISHED: 'group_wrapper_finished',
|
||||
WORLD_INFO_ACTIVATED: 'world_info_activated',
|
||||
TEXT_COMPLETION_SETTINGS_READY: 'text_completion_settings_ready',
|
||||
CHAT_COMPLETION_SETTINGS_READY: 'chat_completion_settings_ready',
|
||||
CHAT_COMPLETION_PROMPT_READY: 'chat_completion_prompt_ready',
|
||||
CHARACTER_FIRST_MESSAGE_SELECTED: 'character_first_message_selected',
|
||||
// TODO: Naming convention is inconsistent with other events
|
||||
CHARACTER_DELETED: 'characterDeleted',
|
||||
CHARACTER_DUPLICATED: 'character_duplicated',
|
||||
CHARACTER_RENAMED: 'character_renamed',
|
||||
CHARACTER_RENAMED_IN_PAST_CHAT: 'character_renamed_in_past_chat',
|
||||
/** @deprecated The event is aliased to STREAM_TOKEN_RECEIVED. */
|
||||
SMOOTH_STREAM_TOKEN_RECEIVED: 'stream_token_received',
|
||||
STREAM_TOKEN_RECEIVED: 'stream_token_received',
|
||||
STREAM_REASONING_DONE: 'stream_reasoning_done',
|
||||
FILE_ATTACHMENT_DELETED: 'file_attachment_deleted',
|
||||
WORLDINFO_FORCE_ACTIVATE: 'worldinfo_force_activate',
|
||||
OPEN_CHARACTER_LIBRARY: 'open_character_library',
|
||||
ONLINE_STATUS_CHANGED: 'online_status_changed',
|
||||
IMAGE_SWIPED: 'image_swiped',
|
||||
CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
|
||||
CONNECTION_PROFILE_CREATED: 'connection_profile_created',
|
||||
CONNECTION_PROFILE_DELETED: 'connection_profile_deleted',
|
||||
CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
|
||||
TOOL_CALLS_PERFORMED: 'tool_calls_performed',
|
||||
TOOL_CALLS_RENDERED: 'tool_calls_rendered',
|
||||
CHARACTER_MANAGEMENT_DROPDOWN: 'charManagementDropdown',
|
||||
SECRET_WRITTEN: 'secret_written',
|
||||
SECRET_DELETED: 'secret_deleted',
|
||||
SECRET_ROTATED: 'secret_rotated',
|
||||
SECRET_EDITED: 'secret_edited',
|
||||
PRESET_CHANGED: 'preset_changed',
|
||||
PRESET_DELETED: 'preset_deleted',
|
||||
PRESET_RENAMED: 'preset_renamed',
|
||||
PRESET_RENAMED_BEFORE: 'preset_renamed_before',
|
||||
MAIN_API_CHANGED: 'main_api_changed',
|
||||
WORLDINFO_ENTRIES_LOADED: 'worldinfo_entries_loaded',
|
||||
WORLDINFO_SCAN_DONE: 'worldinfo_scan_done',
|
||||
MEDIA_ATTACHMENT_DELETED: 'media_attachment_deleted',
|
||||
};
|
||||
|
||||
export const eventSource = new EventEmitter([event_types.APP_READY]);
|
||||
321
web-app/public/scripts/extensions-slashcommands.js
Normal file
321
web-app/public/scripts/extensions-slashcommands.js
Normal file
@@ -0,0 +1,321 @@
|
||||
import { disableExtension, enableExtension, extension_settings, extensionNames } from './extensions.js';
|
||||
import { SlashCommand } from './slash-commands/SlashCommand.js';
|
||||
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
|
||||
import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
|
||||
import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
|
||||
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
|
||||
import { equalsIgnoreCaseAndAccents, isFalseBoolean, isTrueBoolean } from './utils.js';
|
||||
|
||||
/**
|
||||
* @param {'enable' | 'disable' | 'toggle'} action - The action to perform on the extension
|
||||
* @typedef {import('./slash-commands/SlashCommand.js').NamedArguments | import('./slash-commands/SlashCommand.js').NamedArgumentsCapture} NamedArgumentsAssignment
|
||||
* @returns {(args: NamedArgumentsAssignment, extensionName: string | SlashCommandClosure) => Promise<string>}
|
||||
*/
|
||||
function getExtensionActionCallback(action) {
|
||||
return async (args, extensionName) => {
|
||||
if (args?.reload instanceof SlashCommandClosure) throw new Error('\'reload\' argument cannot be a closure.');
|
||||
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
|
||||
if (!extensionName) {
|
||||
toastr.warning(`Extension name must be provided as an argument to ${action} this extension.`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const reload = !isFalseBoolean(args?.reload?.toString());
|
||||
const internalExtensionName = findExtension(extensionName);
|
||||
if (!internalExtensionName) {
|
||||
toastr.warning(`Extension ${extensionName} does not exist.`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
|
||||
|
||||
if (action === 'enable' && isEnabled) {
|
||||
toastr.info(`Extension ${extensionName} is already enabled.`);
|
||||
return internalExtensionName;
|
||||
}
|
||||
|
||||
if (action === 'disable' && !isEnabled) {
|
||||
toastr.info(`Extension ${extensionName} is already disabled.`);
|
||||
return internalExtensionName;
|
||||
}
|
||||
|
||||
if (action === 'toggle') {
|
||||
action = isEnabled ? 'disable' : 'enable';
|
||||
}
|
||||
|
||||
if (reload) {
|
||||
toastr.info(`${action.charAt(0).toUpperCase() + action.slice(1)}ing extension ${extensionName} and reloading...`);
|
||||
|
||||
// Clear input, so it doesn't stay because the command didn't "finish",
|
||||
// and wait for a bit to both show the toast and let the clear bubble through.
|
||||
$('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (action === 'enable') {
|
||||
await enableExtension(internalExtensionName, reload);
|
||||
} else {
|
||||
await disableExtension(internalExtensionName, reload);
|
||||
}
|
||||
|
||||
toastr.success(`Extension ${extensionName} ${action}d.`);
|
||||
|
||||
|
||||
console.info(`Extension ${action}ed: ${extensionName}`);
|
||||
if (!reload) {
|
||||
console.info('Reload not requested, so page needs to be reloaded manually for changes to take effect.');
|
||||
}
|
||||
|
||||
return internalExtensionName;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an extension by name, allowing omission of the "third-party/" prefix.
|
||||
*
|
||||
* @param {string} name - The name of the extension to find
|
||||
* @returns {string?} - The matched extension name or undefined if not found
|
||||
*/
|
||||
function findExtension(name) {
|
||||
return extensionNames.find(extName => {
|
||||
return equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides an array of SlashCommandEnumValue objects based on the extension names.
|
||||
* Each object contains the name of the extension and a description indicating if it is a third-party extension.
|
||||
*
|
||||
* @returns {SlashCommandEnumValue[]} An array of SlashCommandEnumValue objects
|
||||
*/
|
||||
const extensionNamesEnumProvider = () => extensionNames.map(name => {
|
||||
const isThirdParty = name.startsWith('third-party/');
|
||||
if (isThirdParty) name = name.slice('third-party/'.length);
|
||||
|
||||
const description = isThirdParty ? 'third party extension' : null;
|
||||
|
||||
return new SlashCommandEnumValue(name, description, !isThirdParty ? enumTypes.name : enumTypes.enum);
|
||||
});
|
||||
|
||||
export function registerExtensionSlashCommands() {
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'extension-enable',
|
||||
callback: getExtensionActionCallback('enable'),
|
||||
returns: 'The internal extension name',
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'reload',
|
||||
description: 'Whether to reload the page after enabling the extension',
|
||||
typeList: [ARGUMENT_TYPE.BOOLEAN],
|
||||
defaultValue: 'true',
|
||||
enumList: commonEnumProviders.boolean('trueFalse')(),
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Extension name',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumProvider: extensionNamesEnumProvider,
|
||||
forceEnum: true,
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Enables a specified extension.
|
||||
</div>
|
||||
<div>
|
||||
By default, the page will be reloaded automatically, stopping any further commands.<br />
|
||||
If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay disabled until refreshed.
|
||||
The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example:</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<pre><code class="language-stscript">/extension-enable Summarize</code></pre>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'extension-disable',
|
||||
callback: getExtensionActionCallback('disable'),
|
||||
returns: 'The internal extension name',
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'reload',
|
||||
description: 'Whether to reload the page after disabling the extension',
|
||||
typeList: [ARGUMENT_TYPE.BOOLEAN],
|
||||
defaultValue: 'true',
|
||||
enumList: commonEnumProviders.boolean('trueFalse')(),
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Extension name',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumProvider: extensionNamesEnumProvider,
|
||||
forceEnum: true,
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Disables a specified extension.
|
||||
</div>
|
||||
<div>
|
||||
By default, the page will be reloaded automatically, stopping any further commands.<br />
|
||||
If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay enabled until refreshed.
|
||||
The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example:</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<pre><code class="language-stscript">/extension-disable Summarize</code></pre>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'extension-toggle',
|
||||
callback: async (args, extensionName) => {
|
||||
if (args?.state instanceof SlashCommandClosure) throw new Error('\'state\' argument cannot be a closure.');
|
||||
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
|
||||
|
||||
const action = isTrueBoolean(args?.state?.toString()) ? 'enable' :
|
||||
isFalseBoolean(args?.state?.toString()) ? 'disable' :
|
||||
'toggle';
|
||||
|
||||
return await getExtensionActionCallback(action)(args, extensionName);
|
||||
},
|
||||
returns: 'The internal extension name',
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'reload',
|
||||
description: 'Whether to reload the page after toggling the extension',
|
||||
typeList: [ARGUMENT_TYPE.BOOLEAN],
|
||||
defaultValue: 'true',
|
||||
enumList: commonEnumProviders.boolean('trueFalse')(),
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'state',
|
||||
description: 'Explicitly set the state of the extension (true to enable, false to disable). If not provided, the state will be toggled to the opposite of the current state.',
|
||||
typeList: [ARGUMENT_TYPE.BOOLEAN],
|
||||
enumList: commonEnumProviders.boolean('trueFalse')(),
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Extension name',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumProvider: extensionNamesEnumProvider,
|
||||
forceEnum: true,
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Toggles the state of a specified extension.
|
||||
</div>
|
||||
<div>
|
||||
By default, the page will be reloaded automatically, stopping any further commands.<br />
|
||||
If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay in its current state until refreshed.
|
||||
The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example:</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<pre><code class="language-stscript">/extension-toggle Summarize</code></pre>
|
||||
</li>
|
||||
<li>
|
||||
<pre><code class="language-stscript">/extension-toggle Summarize state=true</code></pre>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'extension-state',
|
||||
callback: async (_, extensionName) => {
|
||||
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
|
||||
const internalExtensionName = findExtension(extensionName);
|
||||
if (!internalExtensionName) {
|
||||
toastr.warning(`Extension ${extensionName} does not exist.`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
|
||||
return String(isEnabled);
|
||||
},
|
||||
returns: 'The state of the extension, whether it is enabled.',
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Extension name',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumProvider: extensionNamesEnumProvider,
|
||||
forceEnum: true,
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Returns the state of a specified extension (true if enabled, false if disabled).
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example:</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<pre><code class="language-stscript">/extension-state Summarize</code></pre>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'extension-exists',
|
||||
aliases: ['extension-installed'],
|
||||
callback: async (_, extensionName) => {
|
||||
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
|
||||
const exists = findExtension(extensionName) !== undefined;
|
||||
return exists ? 'true' : 'false';
|
||||
},
|
||||
returns: 'Whether the extension exists and is installed.',
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Extension name',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumProvider: extensionNamesEnumProvider,
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Checks if a specified extension exists.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Example:</strong>
|
||||
<ul>
|
||||
<li>
|
||||
<pre><code class="language-stscript">/extension-exists SillyTavern-LALib</code></pre>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'reload-page',
|
||||
callback: async () => {
|
||||
toastr.info('Reloading the page...');
|
||||
location.reload();
|
||||
return '';
|
||||
},
|
||||
helpString: 'Reloads the current page. All further commands will not be processed.',
|
||||
}));
|
||||
}
|
||||
1649
web-app/public/scripts/extensions.js
Normal file
1649
web-app/public/scripts/extensions.js
Normal file
File diff suppressed because it is too large
Load Diff
9
web-app/public/scripts/extensions/assets/character.html
Normal file
9
web-app/public/scripts/extensions/assets/character.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="characterAsset">
|
||||
<div class="characterAssetName">{{name}}</div>
|
||||
<img class="characterAssetImage" alt="{{name}}" src="{{url}}" />
|
||||
<div class="characterAssetDescription" title="{{description}}">{{description}}</div>
|
||||
<div class="characterAssetButtons flex-container">
|
||||
<div class="characterAssetDownloadButton right_menu_button fa-fw fa-solid fa-download" title="Download"></div>
|
||||
<div class="characterAssetCheckMark right_menu_button fa-fw fa-solid fa-check" title="Installed"></div>
|
||||
</div>
|
||||
</div>
|
||||
506
web-app/public/scripts/extensions/assets/index.js
Normal file
506
web-app/public/scripts/extensions/assets/index.js
Normal file
@@ -0,0 +1,506 @@
|
||||
/*
|
||||
TODO:
|
||||
*/
|
||||
//const DEBUG_TONY_SAMA_FORK_MODE = true
|
||||
|
||||
import { DOMPurify } from '../../../lib.js';
|
||||
import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
|
||||
import { deleteExtension, extensionNames, getContext, installExtension, renderExtensionTemplateAsync } from '../../extensions.js';
|
||||
import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
|
||||
import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
|
||||
import { accountStorage } from '../../util/AccountStorage.js';
|
||||
import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
|
||||
import { t, translate } from '../../i18n.js';
|
||||
export { MODULE_NAME };
|
||||
|
||||
const MODULE_NAME = 'assets';
|
||||
const DEBUG_PREFIX = '<Assets module> ';
|
||||
let previewAudio = null;
|
||||
let ASSETS_JSON_URL = 'https://raw.githubusercontent.com/SillyTavern/SillyTavern-Content/main/index.json';
|
||||
|
||||
|
||||
// DBG
|
||||
//if (DEBUG_TONY_SAMA_FORK_MODE)
|
||||
// ASSETS_JSON_URL = "https://raw.githubusercontent.com/Tony-sama/SillyTavern-Content/main/index.json"
|
||||
let availableAssets = {};
|
||||
let currentAssets = {};
|
||||
|
||||
//#############################//
|
||||
// Extension UI and Settings //
|
||||
//#############################//
|
||||
|
||||
function filterAssets() {
|
||||
const searchValue = String($('#assets_search').val()).toLowerCase().trim();
|
||||
const typeValue = String($('#assets_type_select').val());
|
||||
|
||||
if (typeValue === '') {
|
||||
$('#assets_menu .assets-list-div').show();
|
||||
$('#assets_menu .assets-list-div h3').show();
|
||||
} else {
|
||||
$('#assets_menu .assets-list-div h3').hide();
|
||||
$('#assets_menu .assets-list-div').hide();
|
||||
$(`#assets_menu .assets-list-div[data-type="${typeValue}"]`).show();
|
||||
}
|
||||
|
||||
if (searchValue === '') {
|
||||
$('#assets_menu .asset-block').show();
|
||||
} else {
|
||||
$('#assets_menu .asset-block').hide();
|
||||
$('#assets_menu .asset-block').filter(function () {
|
||||
return $(this).text().toLowerCase().includes(searchValue);
|
||||
}).show();
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_TYPES = {
|
||||
'extension': t`Extensions`,
|
||||
'character': t`Characters`,
|
||||
'ambient': t`Ambient sounds`,
|
||||
'bgm': t`Background music`,
|
||||
'blip': t`Blip sounds`,
|
||||
};
|
||||
|
||||
const EMPTY_AUTHOR = {
|
||||
name: '',
|
||||
url: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the repository author from a given URL.
|
||||
* @param {string} url - The URL of the repository.
|
||||
* @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found.
|
||||
*/
|
||||
function getAuthorFromUrl(url) {
|
||||
const result = structuredClone(EMPTY_AUTHOR);
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0);
|
||||
|
||||
// TODO: Handle non-GitHub URLs if needed
|
||||
if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) {
|
||||
result.name = pathSegments[0];
|
||||
result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.debug(DEBUG_PREFIX, 'Error parsing URL:', error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function downloadAssetsList(url) {
|
||||
updateCurrentAssets().then(async function () {
|
||||
fetch(url, { cache: 'no-cache' })
|
||||
.then(response => response.json())
|
||||
.then(async function(json) {
|
||||
|
||||
availableAssets = {};
|
||||
$('#assets_menu').empty();
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
|
||||
|
||||
for (const i of json) {
|
||||
//console.log(DEBUG_PREFIX,i)
|
||||
if (availableAssets[i['type']] === undefined)
|
||||
availableAssets[i['type']] = [];
|
||||
|
||||
availableAssets[i['type']].push(i);
|
||||
}
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
|
||||
// First extensions, then everything else
|
||||
const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
|
||||
|
||||
$('#assets_type_select').empty();
|
||||
$('#assets_search').val('');
|
||||
$('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
|
||||
|
||||
for (const type of assetTypes) {
|
||||
const text = translate(KNOWN_TYPES[type] || type);
|
||||
const option = $('<option />', { value: type, text: text });
|
||||
$('#assets_type_select').append(option);
|
||||
}
|
||||
|
||||
if (assetTypes.includes('extension')) {
|
||||
$('#assets_type_select').val('extension');
|
||||
}
|
||||
|
||||
$('#assets_type_select').off('change').on('change', filterAssets);
|
||||
$('#assets_search').off('input').on('input', filterAssets);
|
||||
|
||||
for (const assetType of assetTypes) {
|
||||
let assetTypeMenu = $('<div />', { id: 'assets_audio_ambient_div', class: 'assets-list-div' });
|
||||
assetTypeMenu.attr('data-type', assetType);
|
||||
assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide();
|
||||
|
||||
if (assetType == 'extension') {
|
||||
assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
|
||||
}
|
||||
|
||||
for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['name'].localeCompare(b['name']))) {
|
||||
const i = availableAssets[assetType].indexOf(asset);
|
||||
const elemId = `assets_install_${assetType}_${i}`;
|
||||
let element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
|
||||
const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>');
|
||||
element.append(label);
|
||||
|
||||
//if (DEBUG_TONY_SAMA_FORK_MODE)
|
||||
// asset["url"] = asset["url"].replace("https://github.com/SillyTavern/","https://github.com/Tony-sama/"); // DBG
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Checking asset', asset['id'], asset['url']);
|
||||
|
||||
const assetInstall = async function () {
|
||||
element.off('click');
|
||||
label.removeClass('fa-download');
|
||||
this.classList.add('asset-download-button-loading');
|
||||
await installAsset(asset['url'], assetType, asset['id']);
|
||||
label.addClass('fa-check');
|
||||
this.classList.remove('asset-download-button-loading');
|
||||
element.on('click', assetDelete);
|
||||
element.on('mouseenter', function () {
|
||||
label.removeClass('fa-check');
|
||||
label.addClass('fa-trash');
|
||||
label.addClass('redOverlayGlow');
|
||||
}).on('mouseleave', function () {
|
||||
label.addClass('fa-check');
|
||||
label.removeClass('fa-trash');
|
||||
label.removeClass('redOverlayGlow');
|
||||
});
|
||||
};
|
||||
|
||||
const assetDelete = async function () {
|
||||
if (assetType === 'character') {
|
||||
toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
|
||||
await executeSlashCommandsWithOptions(`/go ${asset['id']}`);
|
||||
return;
|
||||
}
|
||||
element.off('click');
|
||||
await deleteAsset(assetType, asset['id']);
|
||||
label.removeClass('fa-check');
|
||||
label.removeClass('redOverlayGlow');
|
||||
label.removeClass('fa-trash');
|
||||
label.addClass('fa-download');
|
||||
element.off('mouseenter').off('mouseleave');
|
||||
element.on('click', assetInstall);
|
||||
};
|
||||
|
||||
if (isAssetInstalled(assetType, asset['id'])) {
|
||||
console.debug(DEBUG_PREFIX, 'installed, checked');
|
||||
label.toggleClass('fa-download');
|
||||
label.toggleClass('fa-check');
|
||||
element.on('click', assetDelete);
|
||||
element.on('mouseenter', function () {
|
||||
label.removeClass('fa-check');
|
||||
label.addClass('fa-trash');
|
||||
label.addClass('redOverlayGlow');
|
||||
}).on('mouseleave', function () {
|
||||
label.addClass('fa-check');
|
||||
label.removeClass('fa-trash');
|
||||
label.removeClass('redOverlayGlow');
|
||||
});
|
||||
}
|
||||
else {
|
||||
console.debug(DEBUG_PREFIX, 'not installed, unchecked');
|
||||
element.prop('checked', false);
|
||||
element.on('click', assetInstall);
|
||||
}
|
||||
|
||||
console.debug(DEBUG_PREFIX, 'Created element for ', asset['id']);
|
||||
|
||||
const displayName = DOMPurify.sanitize(asset['name'] || asset['id']);
|
||||
const description = DOMPurify.sanitize(asset['description'] || '');
|
||||
const url = isValidUrl(asset['url']) ? asset['url'] : '';
|
||||
const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`;
|
||||
const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple';
|
||||
const toolTag = assetType === 'extension' && asset['tool'];
|
||||
const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
|
||||
|
||||
const assetBlock = $('<i></i>')
|
||||
.append(element)
|
||||
.append(`<div class="flex-container flexFlowColumn flexNoGap wide100p overflowHidden">
|
||||
<span class="asset-name flex-container alignitemscenter">
|
||||
<b>${displayName}</b>
|
||||
<a class="asset_preview" href="${url}" target="_blank" title="${title}">
|
||||
<i class="fa-solid fa-sm ${previewIcon}"></i>
|
||||
</a>` +
|
||||
(toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' +
|
||||
t`Tool` + '</span>' : '') +
|
||||
'<span class="expander"></span>' +
|
||||
(author.name ? `<a href="${author.url}" target="_blank" class="asset-author-info"><i class="fa-solid fa-at fa-xs"></i><span>${author.name}</span></a>` : '') +
|
||||
`</span>
|
||||
<small class="asset-description">
|
||||
${description}
|
||||
</small>
|
||||
</div>`);
|
||||
|
||||
assetBlock.find('.tag').on('click', function (e) {
|
||||
const a = document.createElement('a');
|
||||
a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/';
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
});
|
||||
|
||||
if (assetType === 'character') {
|
||||
if (asset.highlight) {
|
||||
assetBlock.find('.asset-name').append('<i class="fa-solid fa-sm fa-trophy"></i>');
|
||||
}
|
||||
assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset['url']}" alt="${displayName}"></div>`);
|
||||
}
|
||||
|
||||
assetBlock.addClass('asset-block');
|
||||
|
||||
assetTypeMenu.append(assetBlock);
|
||||
}
|
||||
assetTypeMenu.appendTo('#assets_menu');
|
||||
assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
|
||||
}
|
||||
|
||||
filterAssets();
|
||||
$('#assets_filters').show();
|
||||
$('#assets_menu').show();
|
||||
})
|
||||
.catch((error) => {
|
||||
// Info hint if the user maybe... likely accidently was trying to install an extension and we wanna help guide them? uwu :3
|
||||
const installButton = $('#third_party_extension_button');
|
||||
flashHighlight(installButton, 10_000);
|
||||
toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 });
|
||||
|
||||
// Error logged after, to appear on top
|
||||
console.error(error);
|
||||
toastr.error('Problem with assets URL', DEBUG_PREFIX + 'Cannot get assets list');
|
||||
$('#assets-connect-button').addClass('fa-plug-circle-exclamation');
|
||||
$('#assets-connect-button').addClass('redOverlayGlow');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function previewAsset(e) {
|
||||
const href = $(this).attr('href');
|
||||
const audioExtensions = ['.mp3', '.ogg', '.wav'];
|
||||
|
||||
if (audioExtensions.some(ext => href.endsWith(ext))) {
|
||||
e.preventDefault();
|
||||
|
||||
if (previewAudio) {
|
||||
previewAudio.pause();
|
||||
|
||||
if (previewAudio.src === href) {
|
||||
previewAudio = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
previewAudio = new Audio(href);
|
||||
previewAudio.play();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function isAssetInstalled(assetType, filename) {
|
||||
let assetList = currentAssets[assetType];
|
||||
|
||||
if (assetType == 'extension') {
|
||||
const thirdPartyMarker = 'third-party/';
|
||||
assetList = extensionNames.filter(x => x.startsWith(thirdPartyMarker)).map(x => x.replace(thirdPartyMarker, ''));
|
||||
}
|
||||
|
||||
if (assetType == 'character') {
|
||||
assetList = getContext().characters.map(x => x.avatar);
|
||||
}
|
||||
|
||||
for (const i of assetList) {
|
||||
//console.debug(DEBUG_PREFIX,i,filename)
|
||||
if (i.includes(filename))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function installAsset(url, assetType, filename) {
|
||||
console.debug(DEBUG_PREFIX, 'Downloading ', url);
|
||||
const category = assetType;
|
||||
try {
|
||||
if (category === 'extension') {
|
||||
console.debug(DEBUG_PREFIX, 'Installing extension ', url);
|
||||
await installExtension(url, false);
|
||||
console.debug(DEBUG_PREFIX, 'Extension installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = { url, category, filename };
|
||||
const result = await fetch('/api/assets/download', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-cache',
|
||||
});
|
||||
if (result.ok) {
|
||||
console.debug(DEBUG_PREFIX, 'Download success.');
|
||||
if (category === 'character') {
|
||||
console.debug(DEBUG_PREFIX, 'Importing character ', filename);
|
||||
const blob = await result.blob();
|
||||
const file = new File([blob], filename, { type: blob.type });
|
||||
await processDroppedFiles([file]);
|
||||
console.debug(DEBUG_PREFIX, 'Character downloaded.');
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAsset(assetType, filename) {
|
||||
console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename);
|
||||
const category = assetType;
|
||||
try {
|
||||
if (category === 'extension') {
|
||||
console.debug(DEBUG_PREFIX, 'Deleting extension ', filename);
|
||||
await deleteExtension(filename);
|
||||
console.debug(DEBUG_PREFIX, 'Extension deleted.');
|
||||
}
|
||||
|
||||
const body = { category, filename };
|
||||
const result = await fetch('/api/assets/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-cache',
|
||||
});
|
||||
if (result.ok) {
|
||||
console.debug(DEBUG_PREFIX, 'Deletion success.');
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function openCharacterBrowser(forceDefault) {
|
||||
const url = forceDefault ? ASSETS_JSON_URL : String($('#assets-json-url-field').val());
|
||||
const fetchResult = await fetch(url, { cache: 'no-cache' });
|
||||
const json = await fetchResult.json();
|
||||
const characters = json.filter(x => x.type === 'character');
|
||||
|
||||
if (!characters.length) {
|
||||
toastr.error('No characters found in the assets list', 'Character browser');
|
||||
return;
|
||||
}
|
||||
|
||||
const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'market', {}));
|
||||
|
||||
for (const character of characters.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const listElement = template.find(character.highlight ? '.contestWinnersList' : '.featuredCharactersList');
|
||||
const characterElement = $(await renderExtensionTemplateAsync(MODULE_NAME, 'character', character));
|
||||
const downloadButton = characterElement.find('.characterAssetDownloadButton');
|
||||
const checkMark = characterElement.find('.characterAssetCheckMark');
|
||||
const isInstalled = isAssetInstalled('character', character.id);
|
||||
|
||||
downloadButton.toggle(!isInstalled).on('click', async () => {
|
||||
downloadButton.toggleClass('fa-download fa-spinner fa-spin');
|
||||
await installAsset(character.url, 'character', character.id);
|
||||
downloadButton.hide();
|
||||
checkMark.show();
|
||||
});
|
||||
|
||||
checkMark.toggle(isInstalled);
|
||||
|
||||
listElement.append(characterElement);
|
||||
}
|
||||
|
||||
callGenericPopup(template, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, allowVerticalScrolling: true, allowHorizontalScrolling: false });
|
||||
}
|
||||
|
||||
//#############################//
|
||||
// API Calls //
|
||||
//#############################//
|
||||
|
||||
async function updateCurrentAssets() {
|
||||
console.debug(DEBUG_PREFIX, 'Checking installed assets...');
|
||||
try {
|
||||
const result = await fetch('/api/assets/get', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders({ omitContentType: true }),
|
||||
});
|
||||
currentAssets = result.ok ? (await result.json()) : {};
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
console.debug(DEBUG_PREFIX, 'Current assets found:', currentAssets);
|
||||
}
|
||||
|
||||
|
||||
//#############################//
|
||||
// Extension load //
|
||||
//#############################//
|
||||
|
||||
// This function is called when the extension is loaded
|
||||
jQuery(async () => {
|
||||
// This is an example of loading HTML from a file
|
||||
const windowTemplate = await renderExtensionTemplateAsync(MODULE_NAME, 'window', {});
|
||||
const windowHtml = $(windowTemplate);
|
||||
|
||||
const assetsJsonUrl = windowHtml.find('#assets-json-url-field');
|
||||
assetsJsonUrl.val(ASSETS_JSON_URL);
|
||||
|
||||
const charactersButton = windowHtml.find('#assets-characters-button');
|
||||
charactersButton.on('click', async function () {
|
||||
openCharacterBrowser(false);
|
||||
});
|
||||
|
||||
const installHintButton = windowHtml.find('.assets-install-hint-link');
|
||||
installHintButton.on('click', async function () {
|
||||
const installButton = $('#third_party_extension_button');
|
||||
flashHighlight(installButton, 5000);
|
||||
toastr.info(t`Click the flashing button to install extensions.`, t`How to install extensions?`);
|
||||
});
|
||||
|
||||
const connectButton = windowHtml.find('#assets-connect-button');
|
||||
connectButton.on('click', async function () {
|
||||
const url = DOMPurify.sanitize(String(assetsJsonUrl.val()));
|
||||
const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
|
||||
const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
|
||||
|
||||
const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '<span>' + t`Are you sure you want to connect to the following url?` + `</span><var>${url}</var>`, {
|
||||
customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
|
||||
onClose: popup => {
|
||||
if (popup.result) {
|
||||
const rememberValue = popup.inputResults.get('assets-remember');
|
||||
accountStorage.setItem(rememberKey, String(rememberValue));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (confirmation) {
|
||||
try {
|
||||
console.debug(DEBUG_PREFIX, 'Confimation, loading assets...');
|
||||
downloadAssetsList(url);
|
||||
connectButton.removeClass('fa-plug-circle-exclamation');
|
||||
connectButton.removeClass('redOverlayGlow');
|
||||
connectButton.addClass('fa-plug-circle-check');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
toastr.error(`Cannot get assets list from ${url}`);
|
||||
connectButton.removeClass('fa-plug-circle-check');
|
||||
connectButton.addClass('fa-plug-circle-exclamation');
|
||||
connectButton.removeClass('redOverlayGlow');
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.debug(DEBUG_PREFIX, 'Connection refused by user');
|
||||
}
|
||||
});
|
||||
|
||||
windowHtml.find('#assets_filters').hide();
|
||||
$('#assets_container').append(windowHtml);
|
||||
|
||||
eventSource.on(event_types.OPEN_CHARACTER_LIBRARY, async (forceDefault) => {
|
||||
openCharacterBrowser(forceDefault);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="assets-list-git">
|
||||
<span data-i18n="extension_install_1">To download extensions from this page, you need to have </span><a href="https://git-scm.com/downloads" target="_blank">Git</a><span data-i18n="extension_install_2"> installed.</span><br>
|
||||
<span data-i18n="extension_install_3">Click the </span><i class="fa-solid fa-sm fa-arrow-up-right-from-square"></i><span data-i18n="extension_install_4"> icon to visit the Extension's repo for tips on how to use it.</span>
|
||||
</div>
|
||||
11
web-app/public/scripts/extensions/assets/manifest.json
Normal file
11
web-app/public/scripts/extensions/assets/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"display_name": "Assets",
|
||||
"loading_order": 15,
|
||||
"requires": [],
|
||||
"optional": [],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "Keij#6799",
|
||||
"version": "0.1.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
||||
19
web-app/public/scripts/extensions/assets/market.html
Normal file
19
web-app/public/scripts/extensions/assets/market.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="flex-container flexFlowColumn padding5">
|
||||
<div class="contestWinners flex-container flexFlowColumn">
|
||||
<h3 class="flex-container alignItemsBaseline justifyCenter" data-i18n="[title]These characters are the winners of character design contests and have outstandable quality." title="These characters are the winners of character design contests and have outstandable quality.">
|
||||
<span data-i18n="Contest Winners">Contest Winners</span>
|
||||
<i class="fa-solid fa-star"></i>
|
||||
</h3>
|
||||
<div class="contestWinnersList characterAssetList">
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="featuredCharacters flex-container flexFlowColumn">
|
||||
<h3 class="flex-container alignItemsBaseline justifyCenter" data-i18n="[title]These characters are the finalists of character design contests and have remarkable quality." title="These characters are the finalists of character design contests and have remarkable quality.">
|
||||
<span data-i18n="Featured Characters">Featured Characters</span>
|
||||
<i class="fa-solid fa-thumbs-up"></i>
|
||||
</h3>
|
||||
<div class="featuredCharactersList characterAssetList">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
200
web-app/public/scripts/extensions/assets/style.css
Normal file
200
web-app/public/scripts/extensions/assets/style.css
Normal file
@@ -0,0 +1,200 @@
|
||||
#assets-json-url-field {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
#assets-connect-button {
|
||||
width: 15%;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.assets-url-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.assets-install-hint-link {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.assets-connect-div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.assets-list-git {
|
||||
font-size: calc(var(--mainFontSize) * 0.8);
|
||||
opacity: 0.8;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.assets-list-div h3 {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.assets-list-div i a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.assets-list-div>i {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
padding: 10px 5px;
|
||||
font-style: normal;
|
||||
gap: 5px;
|
||||
border-bottom: 1px solid var(--SmartThemeBorderColor);
|
||||
}
|
||||
|
||||
.assets-list-div i span:first-of-type {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.asset-download-button {
|
||||
position: relative;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
filter: none !important;
|
||||
}
|
||||
|
||||
.asset-download-button:active {
|
||||
background: #007a63;
|
||||
}
|
||||
|
||||
.asset-download-button-text {
|
||||
font: bold 20px "Quicksand", san-serif;
|
||||
color: #ffffff;
|
||||
transition: all var(--animation-duration-2x);
|
||||
}
|
||||
|
||||
.asset-download-button-loading .asset-download-button-text {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.asset-download-button-loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: asset-download-button-loading-spinner 1s ease infinite;
|
||||
}
|
||||
|
||||
.asset-name .avatar {
|
||||
--imgSize: 30px !important;
|
||||
flex: unset;
|
||||
width: var(--imgSize);
|
||||
height: var(--imgSize);
|
||||
}
|
||||
|
||||
.asset-name .avatar img {
|
||||
width: var(--imgSize);
|
||||
height: var(--imgSize);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
object-position: center center;
|
||||
}
|
||||
|
||||
@keyframes asset-download-button-loading-spinner {
|
||||
from {
|
||||
transform: rotate(0turn);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
.characterAssetList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.characterAsset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
background-color: var(--black30a);
|
||||
border-radius: 10px;
|
||||
width: 17%;
|
||||
min-width: 150px;
|
||||
margin: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.characterAssetName {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.characterAssetImage {
|
||||
max-height: 140px;
|
||||
object-fit: scale-down;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.characterAssetDescription {
|
||||
font-size: 0.75em;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.characterAssetButtons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.asset-name .tag {
|
||||
gap: 5px;
|
||||
align-items: baseline;
|
||||
font-size: calc(var(--mainFontSize)* 0.8);
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.asset-name .asset-author-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asset-name .asset-author-info>span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.asset-name .asset-author-info:hover {
|
||||
opacity: 1;
|
||||
transition: opacity var(--animation-duration) ease-in-out;
|
||||
}
|
||||
|
||||
.asset-name>b {
|
||||
font-weight: 600;
|
||||
}
|
||||
46
web-app/public/scripts/extensions/assets/window.html
Normal file
46
web-app/public/scripts/extensions/assets/window.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<div id="assets_ui">
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<b data-i18n="Download Extensions & Assets">Download Extensions & Assets</b>
|
||||
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
|
||||
</div>
|
||||
<div class="inline-drawer-content">
|
||||
<small>
|
||||
<span data-i18n="Load a custom asset list or select">
|
||||
Load a custom asset list or select
|
||||
</span>
|
||||
<a class="assets-install-hint-link" data-i18n="Install extension">Install Extension</a>
|
||||
<span data-i18n="to install 3rd party extensions.">
|
||||
to install 3rd party extensions.
|
||||
</span>
|
||||
</small>
|
||||
<div class="assets-url-block m-b-1 m-t-1">
|
||||
<label for="assets-json-url-field" data-i18n="Assets URL">Assets URL</label>
|
||||
<small data-i18n="[title]load_asset_list_desc" title="Load a list of extensions & assets based on an asset list file.
|
||||
|
||||
The default Asset URL in this field points to the list of offical first party extensions and assets.
|
||||
If you have a custom asset list, you can insert it here.
|
||||
|
||||
To install a single 3rd party extension, use the "Install Extensions" button on the top right.">
|
||||
<span data-i18n="Load an asset list">Load an asset list</span>
|
||||
<div class="fa-solid fa-circle-info opacity50p"></div>
|
||||
</small>
|
||||
<div class="assets-connect-div">
|
||||
<input id="assets-json-url-field" class="text_pole widthUnset flex1">
|
||||
<i id="assets-connect-button" class="menu_button fa-solid fa-plug-circle-exclamation fa-xl redOverlayGlow" title="Load Asset List" data-i18n="[title]Load Asset List"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div id="assets_filters" class="flex-container">
|
||||
<select id="assets_type_select" class="text_pole flex1">
|
||||
</select>
|
||||
<input id="assets_search" class="text_pole flex1" data-i18n="[placeholder]Search" placeholder="Search" type="search">
|
||||
<div id="assets-characters-button" class="menu_button menu_button_icon">
|
||||
<i class="fa-solid fa-image-portrait"></i>
|
||||
<span data-i18n="Characters">Characters</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="assets_menu">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div id="attachFile" class="list-group-item flex-container flexGap5" data-i18n="[title]Attach a file or image to a current chat." title="Attach a file or image to a current chat.">
|
||||
<div class="fa-fw fa-solid fa-paperclip extensionsMenuExtensionButton"></div>
|
||||
<span data-i18n="Attach a File">Attach a File</span>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
<div>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<label for="fandomScrapeInput" data-i18n="Enter a URL or the ID of a Fandom wiki page to scrape:">
|
||||
Enter a URL or the ID of a Fandom wiki page to scrape:
|
||||
</label>
|
||||
<small>
|
||||
<span data-i18n="Examples:">Examples:</span>
|
||||
<code>https://harrypotter.fandom.com/</code>
|
||||
<span data-i18n="or">or</span>
|
||||
<code>harrypotter</code>
|
||||
</small>
|
||||
<input type="text" id="fandomScrapeInput" name="fandomScrapeInput" class="text_pole" placeholder="">
|
||||
</div>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<label for="fandomScrapeFilter">
|
||||
Optional regex to pick the content by its title:
|
||||
</label>
|
||||
<small>
|
||||
<span data-i18n="Example:">Example:</span>
|
||||
<code>/(Azkaban|Weasley)/gi</code>
|
||||
</small>
|
||||
<input type="text" id="fandomScrapeFilter" name="fandomScrapeFilter" class="text_pole" placeholder="">
|
||||
</div>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<label>
|
||||
Output format:
|
||||
</label>
|
||||
<label class="checkbox_label justifyLeft" for="fandomScrapeOutputSingle">
|
||||
<input id="fandomScrapeOutputSingle" type="radio" name="fandomScrapeOutput" value="single" checked>
|
||||
<div class="flex-container flexFlowColumn flexNoGap">
|
||||
<span data-i18n="Single file">
|
||||
Single file
|
||||
</span>
|
||||
<small data-i18n="All articles will be concatenated into a single file.">
|
||||
All articles will be concatenated into a single file.
|
||||
</small>
|
||||
</div>
|
||||
</label>
|
||||
<label class="checkbox_label justifyLeft" for="fandomScrapeOutputMulti">
|
||||
<input id="fandomScrapeOutputMulti" type="radio" name="fandomScrapeOutput" value="multi">
|
||||
<div class="flex-container flexFlowColumn flexNoGap">
|
||||
<span data-i18n="File per article">
|
||||
File per article
|
||||
</span>
|
||||
<small data-i18n="Each article will be saved as a separate file.">
|
||||
Not recommended. Each article will be saved as a separate file.
|
||||
</small>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="flex-container justifyCenter alignItemsBaseline">
|
||||
<span>Save <span class="droppedFilesCount">{{count}}</span> file(s) to...</span>
|
||||
<select class="droppedFilesTarget">
|
||||
{{#each targets}}
|
||||
<option value="{{this}}">{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
410
web-app/public/scripts/extensions/attachments/index.js
Normal file
410
web-app/public/scripts/extensions/attachments/index.js
Normal file
@@ -0,0 +1,410 @@
|
||||
import { event_types, eventSource, saveSettingsDebounced } from '../../../script.js';
|
||||
import { deleteAttachment, getDataBankAttachments, getDataBankAttachmentsForSource, getFileAttachment, uploadFileAttachmentToServer } from '../../chats.js';
|
||||
import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
|
||||
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
|
||||
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
|
||||
import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
|
||||
import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
|
||||
import { SlashCommandExecutor } from '../../slash-commands/SlashCommandExecutor.js';
|
||||
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
||||
|
||||
/**
|
||||
* List of attachment sources
|
||||
* @type {string[]}
|
||||
*/
|
||||
const TYPES = ['global', 'character', 'chat'];
|
||||
const FIELDS = ['name', 'url'];
|
||||
|
||||
/**
|
||||
* Get attachments from the data bank. Includes disabled attachments.
|
||||
* @param {string} [source] Source for the attachments
|
||||
* @returns {import('../../chats').FileAttachment[]} List of attachments
|
||||
*/
|
||||
function getAttachments(source) {
|
||||
if (!source || !TYPES.includes(source)) {
|
||||
return getDataBankAttachments(true);
|
||||
}
|
||||
|
||||
return getDataBankAttachmentsForSource(source, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachment by a single name or URL.
|
||||
* @param {import('../../chats').FileAttachment[]} attachments List of attachments
|
||||
* @param {string} value Name or URL of the attachment
|
||||
* @returns {import('../../chats').FileAttachment} Attachment
|
||||
*/
|
||||
function getAttachmentByField(attachments, value) {
|
||||
const match = (a) => String(a).trim().toLowerCase() === String(value).trim().toLowerCase();
|
||||
const fullMatchByURL = attachments.find(it => match(it.url));
|
||||
const fullMatchByName = attachments.find(it => match(it.name));
|
||||
return fullMatchByURL || fullMatchByName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachment by multiple fields.
|
||||
* @param {import('../../chats').FileAttachment[]} attachments List of attachments
|
||||
* @param {string[]} values Name and URL of the attachment to search for
|
||||
* @returns
|
||||
*/
|
||||
function getAttachmentByFields(attachments, values) {
|
||||
for (const value of values) {
|
||||
const attachment = getAttachmentByField(attachments, value);
|
||||
if (attachment) {
|
||||
return attachment;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for listing attachments in the data bank.
|
||||
* @param {object} args Named arguments
|
||||
* @returns {string} JSON string of the list of attachments
|
||||
*/
|
||||
function listDataBankAttachments(args) {
|
||||
const attachments = getAttachments(args?.source);
|
||||
const field = args?.field;
|
||||
return JSON.stringify(attachments.map(a => FIELDS.includes(field) ? a[field] : a.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for getting text from an attachment in the data bank.
|
||||
* @param {object} args Named arguments
|
||||
* @param {string} value Name or URL of the attachment
|
||||
* @returns {Promise<string>} Content of the attachment
|
||||
*/
|
||||
async function getDataBankText(args, value) {
|
||||
if (!value) {
|
||||
toastr.warning('No attachment name or URL provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = getAttachments(args?.source);
|
||||
const attachment = getAttachmentByField(attachments, value);
|
||||
|
||||
if (!attachment) {
|
||||
toastr.warning('Attachment not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await getFileAttachment(attachment.url);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for adding an attachment to the data bank.
|
||||
* @param {object} args Named arguments
|
||||
* @param {string} value Content of the attachment
|
||||
* @returns {Promise<string>} URL of the attachment
|
||||
*/
|
||||
async function uploadDataBankAttachment(args, value) {
|
||||
const source = args?.source && TYPES.includes(args.source) ? args.source : 'chat';
|
||||
const name = args?.name || new Date().toLocaleString();
|
||||
const file = new File([value], name, { type: 'text/plain' });
|
||||
const url = await uploadFileAttachmentToServer(file, source);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for updating an attachment in the data bank.
|
||||
* @param {object} args Named arguments
|
||||
* @param {string} value Content of the attachment
|
||||
* @returns {Promise<string>} URL of the attachment
|
||||
*/
|
||||
async function updateDataBankAttachment(args, value) {
|
||||
const source = args?.source && TYPES.includes(args.source) ? args.source : 'chat';
|
||||
const attachments = getAttachments(source);
|
||||
const attachment = getAttachmentByFields(attachments, [args?.url, args?.name]);
|
||||
|
||||
if (!attachment) {
|
||||
toastr.warning('Attachment not found.');
|
||||
return '';
|
||||
}
|
||||
|
||||
await deleteAttachment(attachment, source, () => { }, false);
|
||||
const file = new File([value], attachment.name, { type: 'text/plain' });
|
||||
const url = await uploadFileAttachmentToServer(file, source);
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for deleting an attachment from the data bank.
|
||||
* @param {object} args Named arguments
|
||||
* @param {string} value Name or URL of the attachment
|
||||
* @returns {Promise<string>} Empty string
|
||||
*/
|
||||
async function deleteDataBankAttachment(args, value) {
|
||||
const source = args?.source && TYPES.includes(args.source) ? args.source : 'chat';
|
||||
const attachments = getAttachments(source);
|
||||
const attachment = getAttachmentByField(attachments, value);
|
||||
|
||||
if (!attachment) {
|
||||
toastr.warning('Attachment not found.');
|
||||
return '';
|
||||
}
|
||||
|
||||
await deleteAttachment(attachment, source, () => { }, false);
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for disabling an attachment in the data bank.
|
||||
* @param {object} args Named arguments
|
||||
* @param {string} value Name or URL of the attachment
|
||||
* @returns {Promise<string>} Empty string
|
||||
*/
|
||||
async function disableDataBankAttachment(args, value) {
|
||||
const attachments = getAttachments(args?.source);
|
||||
const attachment = getAttachmentByField(attachments, value);
|
||||
|
||||
if (!attachment) {
|
||||
toastr.warning('Attachment not found.');
|
||||
return '';
|
||||
}
|
||||
|
||||
if (extension_settings.disabled_attachments.includes(attachment.url)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
extension_settings.disabled_attachments.push(attachment.url);
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for enabling an attachment in the data bank.
|
||||
* @param {object} args Named arguments
|
||||
* @param {string} value Name or URL of the attachment
|
||||
* @returns {Promise<string>} Empty string
|
||||
*/
|
||||
async function enableDataBankAttachment(args, value) {
|
||||
const attachments = getAttachments(args?.source);
|
||||
const attachment = getAttachmentByField(attachments, value);
|
||||
|
||||
if (!attachment) {
|
||||
toastr.warning('Attachment not found.');
|
||||
return '';
|
||||
}
|
||||
|
||||
const index = extension_settings.disabled_attachments.indexOf(attachment.url);
|
||||
if (index === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
extension_settings.disabled_attachments.splice(index, 1);
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanUpAttachments() {
|
||||
let shouldSaveSettings = false;
|
||||
if (extension_settings.character_attachments) {
|
||||
Object.values(extension_settings.character_attachments).flat().filter(a => a.text).forEach(a => {
|
||||
shouldSaveSettings = true;
|
||||
delete a.text;
|
||||
});
|
||||
}
|
||||
if (Array.isArray(extension_settings.attachments)) {
|
||||
extension_settings.attachments.filter(a => a.text).forEach(a => {
|
||||
shouldSaveSettings = true;
|
||||
delete a.text;
|
||||
});
|
||||
}
|
||||
if (shouldSaveSettings) {
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up character attachments when a character is deleted.
|
||||
* @param {{character: Character}} data Event data
|
||||
*/
|
||||
function cleanUpCharacterAttachments(data) {
|
||||
const avatar = data?.character?.avatar;
|
||||
if (!avatar) return;
|
||||
if (Array.isArray(extension_settings?.character_attachments?.[avatar])) {
|
||||
delete extension_settings.character_attachments[avatar];
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle character rename event to update character attachments.
|
||||
* @param {string} oldAvatar Old avatar name
|
||||
* @param {string} newAvatar New avatar name
|
||||
*/
|
||||
function handleCharacterRename(oldAvatar, newAvatar) {
|
||||
if (!oldAvatar || !newAvatar) return;
|
||||
if (Array.isArray(extension_settings?.character_attachments?.[oldAvatar])) {
|
||||
extension_settings.character_attachments[newAvatar] = extension_settings.character_attachments[oldAvatar];
|
||||
delete extension_settings.character_attachments[oldAvatar];
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
jQuery(async () => {
|
||||
eventSource.on(event_types.APP_READY, cleanUpAttachments);
|
||||
eventSource.on(event_types.CHARACTER_DELETED, cleanUpCharacterAttachments);
|
||||
eventSource.on(event_types.CHARACTER_RENAMED, handleCharacterRename);
|
||||
const manageButton = await renderExtensionTemplateAsync('attachments', 'manage-button', {});
|
||||
const attachButton = await renderExtensionTemplateAsync('attachments', 'attach-button', {});
|
||||
$('#data_bank_wand_container').append(manageButton);
|
||||
$('#attach_file_wand_container').append(attachButton);
|
||||
|
||||
/** A collection of local enum providers for this context of data bank */
|
||||
const localEnumProviders = {
|
||||
/**
|
||||
* All attachments in the data bank based on the source argument. If not provided, defaults to 'chat'.
|
||||
* @param {'name' | 'url'} returnField - Whether the enum should return the 'name' field or the 'url'
|
||||
* @param {'chat' | 'character' | 'global' | ''} fallbackSource - The source to use if the source argument is not provided. Empty string to use all sources.
|
||||
* */
|
||||
attachments: (returnField = 'name', fallbackSource = 'chat') => (/** @type {SlashCommandExecutor} */ executor) => {
|
||||
const source = executor.namedArgumentList.find(it => it.name == 'source')?.value ?? fallbackSource;
|
||||
if (source instanceof SlashCommandClosure) throw new Error('Argument \'source\' does not support closures');
|
||||
const attachments = getAttachments(source);
|
||||
|
||||
return attachments.map(attachment => new SlashCommandEnumValue(
|
||||
returnField === 'name' ? attachment.name : attachment.url,
|
||||
`${enumIcons.getStateIcon(!extension_settings.disabled_attachments.includes(attachment.url))} [${source}] ${returnField === 'url' ? attachment.name : attachment.url}`,
|
||||
enumTypes.enum, enumIcons.file));
|
||||
},
|
||||
};
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'db',
|
||||
callback: () => {
|
||||
document.getElementById('manageAttachments')?.click();
|
||||
return '';
|
||||
},
|
||||
aliases: ['databank', 'data-bank'],
|
||||
helpString: 'Open the data bank',
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'db-list',
|
||||
callback: listDataBankAttachments,
|
||||
aliases: ['databank-list', 'data-bank-list'],
|
||||
helpString: 'List attachments in the Data Bank as a JSON-serialized array. Optionally, provide the source of the attachments and the field to list by.',
|
||||
namedArgumentList: [
|
||||
new SlashCommandNamedArgument('source', 'The source of the attachments.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
|
||||
new SlashCommandNamedArgument('field', 'The field to list by.', ARGUMENT_TYPE.STRING, false, false, 'url', FIELDS),
|
||||
],
|
||||
returns: ARGUMENT_TYPE.LIST,
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'db-get',
|
||||
callback: getDataBankText,
|
||||
aliases: ['databank-get', 'data-bank-get'],
|
||||
helpString: 'Get attachment text from the Data Bank. Either provide the name or URL of the attachment. Optionally, provide the source of the attachment.',
|
||||
namedArgumentList: [
|
||||
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'The name or URL of the attachment.',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
enumProvider: localEnumProviders.attachments('name', ''),
|
||||
}),
|
||||
],
|
||||
returns: ARGUMENT_TYPE.STRING,
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'db-add',
|
||||
callback: uploadDataBankAttachment,
|
||||
aliases: ['databank-add', 'data-bank-add'],
|
||||
helpString: 'Add an attachment to the Data Bank. If name is not provided, it will be generated automatically. Returns the URL of the attachment.',
|
||||
namedArgumentList: [
|
||||
new SlashCommandNamedArgument('source', 'The source for the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
|
||||
new SlashCommandNamedArgument('name', 'The name of the attachment.', ARGUMENT_TYPE.STRING, false, false),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument('The content of the file attachment.', ARGUMENT_TYPE.STRING, true, false),
|
||||
],
|
||||
returns: ARGUMENT_TYPE.STRING,
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'db-update',
|
||||
callback: updateDataBankAttachment,
|
||||
aliases: ['databank-update', 'data-bank-update'],
|
||||
helpString: 'Update an attachment in the Data Bank, preserving its name. Returns a new URL of the attachment.',
|
||||
namedArgumentList: [
|
||||
new SlashCommandNamedArgument('source', 'The source for the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'name',
|
||||
description: 'The name of the attachment.',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
enumProvider: localEnumProviders.attachments('name'),
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'url',
|
||||
description: 'The URL of the attachment.',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
enumProvider: localEnumProviders.attachments('url'),
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument('The content of the file attachment.', ARGUMENT_TYPE.STRING, true, false),
|
||||
],
|
||||
returns: ARGUMENT_TYPE.STRING,
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'db-disable',
|
||||
callback: disableDataBankAttachment,
|
||||
aliases: ['databank-disable', 'data-bank-disable'],
|
||||
helpString: 'Disable an attachment in the Data Bank by its name or URL. Optionally, provide the source of the attachment.',
|
||||
namedArgumentList: [
|
||||
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'The name or URL of the attachment.',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumProvider: localEnumProviders.attachments('name', ''),
|
||||
}),
|
||||
],
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'db-enable',
|
||||
callback: enableDataBankAttachment,
|
||||
aliases: ['databank-enable', 'data-bank-enable'],
|
||||
helpString: 'Enable an attachment in the Data Bank by its name or URL. Optionally, provide the source of the attachment.',
|
||||
namedArgumentList: [
|
||||
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'The name or URL of the attachment.',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumProvider: localEnumProviders.attachments('name', ''),
|
||||
}),
|
||||
],
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'db-delete',
|
||||
callback: deleteDataBankAttachment,
|
||||
aliases: ['databank-delete', 'data-bank-delete'],
|
||||
helpString: 'Delete an attachment from the Data Bank.',
|
||||
namedArgumentList: [
|
||||
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'The name or URL of the attachment.',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumProvider: localEnumProviders.attachments(),
|
||||
}),
|
||||
],
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
|
||||
<div id="manageAttachments" class="list-group-item flex-container flexGap5" title="View global, character, or data files.">
|
||||
<div class="fa-fw fa-solid fa-book-open-reader extensionsMenuExtensionButton"></div>
|
||||
<span data-i18n="Open Data Bank">Open Data Bank</span>
|
||||
</div>
|
||||
157
web-app/public/scripts/extensions/attachments/manager.html
Normal file
157
web-app/public/scripts/extensions/attachments/manager.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<div class="wide100p padding5 dataBankAttachments">
|
||||
<h2 class="marginBot5">
|
||||
<span data-i18n="Data Bank">
|
||||
Data Bank
|
||||
</span>
|
||||
</h2>
|
||||
<div data-i18n="These files will be available for extensions that support attachments (e.g. Vector Storage).">
|
||||
These files will be available for extensions that support attachments (e.g. Vector Storage).
|
||||
</div>
|
||||
<div class="marginTopBot5">
|
||||
<span data-i18n="Supported file types: Plain Text, PDF, Markdown, HTML, EPUB." >
|
||||
Supported file types: Plain Text, PDF, Markdown, HTML, EPUB.
|
||||
</span>
|
||||
<span data-i18n="Drag and drop files here to upload.">
|
||||
Drag and drop files here to upload.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-container marginTopBot5">
|
||||
<input type="search" id="attachmentSearch" class="attachmentSearch text_pole margin0 flex1" placeholder="Search...">
|
||||
<select id="attachmentSort" class="attachmentSort text_pole margin0 flex1 textarea_compact">
|
||||
<option data-sort-field="created" data-sort-order="desc" data-i18n="Date (Newest First)">
|
||||
Date (Newest First)
|
||||
</option>
|
||||
<option data-sort-field="created" data-sort-order="asc" data-i18n="Date (Oldest First)">
|
||||
Date (Oldest First)
|
||||
</option>
|
||||
<option data-sort-field="name" data-sort-order="asc" data-i18n="Name (A-Z)">
|
||||
Name (A-Z)
|
||||
</option>
|
||||
<option data-sort-field="name" data-sort-order="desc" data-i18n="Name (Z-A)">
|
||||
Name (Z-A)
|
||||
</option>
|
||||
<option data-sort-field="size" data-sort-order="asc" data-i18n="Size (Smallest First)">
|
||||
Size (Smallest First)
|
||||
</option>
|
||||
<option data-sort-field="size" data-sort-order="desc" data-i18n="Size (Largest First)">
|
||||
Size (Largest First)
|
||||
</option>
|
||||
</select>
|
||||
<label class="margin0 menu_button menu_button_icon attachmentsBulkEditButton">
|
||||
<i class="fa-solid fa-edit"></i>
|
||||
<span data-i18n="Bulk Edit">Bulk Edit</span>
|
||||
<input type="checkbox" class="displayNone attachmentsBulkEditCheckbox" hidden>
|
||||
</label>
|
||||
</div>
|
||||
<div class="attachmentBulkActionsContainer flex-container marginTopBot5 alignItemsBaseline">
|
||||
<div class="flex-container">
|
||||
<div class="menu_button menu_button_icon bulkActionSelectAll" title="Select all *visible* attachments">
|
||||
<i class="fa-solid fa-check-square"></i>
|
||||
<span data-i18n="Select All">Select All</span>
|
||||
</div>
|
||||
<div class="menu_button menu_button_icon bulkActionSelectNone" title="Deselect all *visible* attachments">
|
||||
<i class="fa-solid fa-square"></i>
|
||||
<span data-i18n="Select None">Select None</span>
|
||||
</div>
|
||||
<div class="menu_button menu_button_icon bulkActionDisable" title="Disable selected attachments">
|
||||
<i class="fa-solid fa-comment-slash"></i>
|
||||
<span data-i18n="Disable">Disable</span>
|
||||
</div>
|
||||
<div class="menu_button menu_button_icon bulkActionEnable" title="Enable selected attachments">
|
||||
<i class="fa-solid fa-comment"></i>
|
||||
<span data-i18n="Enable">Enable</span>
|
||||
</div>
|
||||
<div class="menu_button menu_button_icon bulkActionDelete" title="Delete selected attachments">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
<span data-i18n="Delete">Delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="justifyLeft globalAttachmentsBlock marginBot10">
|
||||
<h3 class="globalAttachmentsTitle margin0 title_restorable">
|
||||
<span data-i18n="Global Attachments">
|
||||
Global Attachments
|
||||
</span>
|
||||
<div class="openActionModalButton menu_button menu_button_icon">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
<span data-i18n="Add">Add</span>
|
||||
</div>
|
||||
</h3>
|
||||
<small data-i18n="These files are available for all characters in all chats.">
|
||||
These files are available for all characters in all chats.
|
||||
</small>
|
||||
<div class="globalAttachmentsList attachmentsList"></div>
|
||||
<hr>
|
||||
</div>
|
||||
<div class="justifyLeft characterAttachmentsBlock marginBot10">
|
||||
<h3 class="characterAttachmentsTitle margin0 title_restorable">
|
||||
<span data-i18n="Character Attachments">
|
||||
Character Attachments
|
||||
</span>
|
||||
<div class="openActionModalButton menu_button menu_button_icon">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
<span data-i18n="Add">Add</span>
|
||||
</div>
|
||||
</h3>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<strong><small class="characterAttachmentsName"></small></strong>
|
||||
<small>
|
||||
<span data-i18n="These files are available for the current character in all chats they are in.">
|
||||
These files are available for the current character in all chats they are in.
|
||||
</span>
|
||||
<span>
|
||||
<span data-i18n="Saved locally. Not exported.">
|
||||
Saved locally. Not exported.
|
||||
</span>
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="characterAttachmentsList attachmentsList"></div>
|
||||
<hr>
|
||||
</div>
|
||||
<div class="justifyLeft chatAttachmentsBlock marginBot10">
|
||||
<h3 class="chatAttachmentsTitle margin0 title_restorable">
|
||||
<span data-i18n="Chat Attachments">
|
||||
Chat Attachments
|
||||
</span>
|
||||
<div class="openActionModalButton menu_button menu_button_icon">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
<span data-i18n="Add">Add</span>
|
||||
</div>
|
||||
</h3>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<strong><small class="chatAttachmentsName"></small></strong>
|
||||
<small data-i18n="These files are available for all characters in the current chat.">
|
||||
These files are available for all characters in the current chat.
|
||||
</small>
|
||||
</div>
|
||||
<div class="chatAttachmentsList attachmentsList"></div>
|
||||
</div>
|
||||
|
||||
<div class="attachmentListItemTemplate template_element">
|
||||
<div class="attachmentListItem flex-container alignItemsCenter flexGap10">
|
||||
<div class="attachmentListItemCheckboxContainer"><input type="checkbox" class="attachmentListItemCheckbox"></div>
|
||||
<div class="attachmentFileIcon fa-solid fa-file-alt"></div>
|
||||
<div class="attachmentListItemName flex1"></div>
|
||||
<small class="attachmentListItemCreated"></small>
|
||||
<small class="attachmentListItemSize"></small>
|
||||
<div class="viewAttachmentButton right_menu_button fa-fw fa-solid fa-magnifying-glass" title="View attachment content"></div>
|
||||
<div class="disableAttachmentButton right_menu_button fa-fw fa-solid fa-comment" title="Disable attachment"></div>
|
||||
<div class="enableAttachmentButton right_menu_button fa-fw fa-solid fa-comment-slash" title="Enable attachment"></div>
|
||||
<div class="moveAttachmentButton right_menu_button fa-fw fa-solid fa-arrows-alt" title="Move attachment"></div>
|
||||
<div class="editAttachmentButton right_menu_button fa-fw fa-solid fa-pencil" title="Edit attachment"></div>
|
||||
<div class="downloadAttachmentButton right_menu_button fa-fw fa-solid fa-download" title="Download attachment"></div>
|
||||
<div class="deleteAttachmentButton right_menu_button fa-fw fa-solid fa-trash" title="Delete attachment"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actionButtonTemplate">
|
||||
<div class="actionButton list-group-item flex-container flexGap5" style="align-items: center;" title="">
|
||||
<i class="actionButtonIcon"></i>
|
||||
<img class="actionButtonImg"/>
|
||||
<span class="actionButtonText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actionButtonsModal popper-modal options-content list-group"></div>
|
||||
</div>
|
||||
11
web-app/public/scripts/extensions/attachments/manifest.json
Normal file
11
web-app/public/scripts/extensions/attachments/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"display_name": "Data Bank (Chat Attachments)",
|
||||
"loading_order": 3,
|
||||
"requires": [],
|
||||
"optional": [],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "Cohee1207",
|
||||
"version": "1.0.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<div>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<label for="scrapeInput" data-i18n="Enter a base URL of the MediaWiki to scrape.">
|
||||
Enter a <strong>base URL</strong> of the MediaWiki to scrape.
|
||||
</label>
|
||||
<i data-i18n="Don't include the page name!">
|
||||
Don't include the page name!
|
||||
</i>
|
||||
<small>
|
||||
<span data-i18n="Examples:">Examples:</span>
|
||||
<code>https://streetcat.wiki/index.php</code>
|
||||
<span data-i18n="or">or</span>
|
||||
<code>https://tcrf.net</code>
|
||||
</small>
|
||||
<input type="text" id="scrapeInput" name="scrapeInput" class="text_pole" placeholder="">
|
||||
</div>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<label for="scrapeFilter">
|
||||
Optional regex to pick the content by its title:
|
||||
</label>
|
||||
<small>
|
||||
<span data-i18n="Example:">Example:</span>
|
||||
<code>/Mr. (Fresh|Snack)/gi</code>
|
||||
</small>
|
||||
<input type="text" id="scrapeFilter" name="scrapeFilter" class="text_pole" placeholder="">
|
||||
</div>
|
||||
<div class="flex-container flexFlowColumn">
|
||||
<label>
|
||||
Output format:
|
||||
</label>
|
||||
<label class="checkbox_label justifyLeft" for="scrapeOutputSingle">
|
||||
<input id="scrapeOutputSingle" type="radio" name="scrapeOutput" value="single" checked>
|
||||
<div class="flex-container flexFlowColumn flexNoGap">
|
||||
<span data-i18n="Single file">
|
||||
Single file
|
||||
</span>
|
||||
<small data-i18n="All articles will be concatenated into a single file.">
|
||||
All articles will be concatenated into a single file.
|
||||
</small>
|
||||
</div>
|
||||
</label>
|
||||
<label class="checkbox_label justifyLeft" for="scrapeOutputMulti">
|
||||
<input id="scrapeOutputMulti" type="radio" name="scrapeOutput" value="multi">
|
||||
<div class="flex-container flexFlowColumn flexNoGap">
|
||||
<span data-i18n="File per article">
|
||||
File per article
|
||||
</span>
|
||||
<small data-i18n="Each article will be saved as a separate file.">
|
||||
Not recommended. Each article will be saved as a separate file.
|
||||
</small>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="flex-container justifyCenter alignItemsBaseline">
|
||||
<span>Move <strong class="moveAttachmentName">{{name}}</strong> to...</span>
|
||||
<select class="moveAttachmentTarget">
|
||||
{{#each targets}}
|
||||
<option value="{{this}}">{{this}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
10
web-app/public/scripts/extensions/attachments/notepad.html
Normal file
10
web-app/public/scripts/extensions/attachments/notepad.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<div class="flex-container flexFlowColumn height100p">
|
||||
<label for="notepadFileName">
|
||||
File Name
|
||||
</label>
|
||||
<input type="text" class="text_pole" id="notepadFileName" name="notepadFileName" value="" />
|
||||
<label>
|
||||
File Content
|
||||
</label>
|
||||
<textarea id="notepadFileContent" name="notepadFileContent" class="text_pole textarea_compact monospace flex1" placeholder="Enter your notes here."></textarea>
|
||||
</div>
|
||||
63
web-app/public/scripts/extensions/attachments/style.css
Normal file
63
web-app/public/scripts/extensions/attachments/style.css
Normal file
@@ -0,0 +1,63 @@
|
||||
.attachmentsList:empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.attachmentsList:empty::before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
content: "No data";
|
||||
font-weight: bolder;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.8;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.attachmentListItem {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.attachmentListItem.disabled .attachmentListItemName {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.attachmentListItem.disabled .attachmentFileIcon {
|
||||
opacity: 0.75;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.attachmentListItemSize {
|
||||
min-width: 4em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.attachmentListItemCreated {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.attachmentListItemCheckboxContainer,
|
||||
.attachmentBulkActionsContainer,
|
||||
.attachmentsBulkEditCheckbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports selector(:has(*)) {
|
||||
.dataBankAttachments:has(.attachmentsBulkEditCheckbox:checked) .attachmentsBulkEditButton {
|
||||
color: var(--golden);
|
||||
}
|
||||
|
||||
.dataBankAttachments:has(.attachmentsBulkEditCheckbox:checked) .attachmentBulkActionsContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dataBankAttachments:has(.attachmentsBulkEditCheckbox:checked) .attachmentListItemCheckboxContainer {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.dataBankAttachments:has(.attachmentsBulkEditCheckbox:checked) .attachmentFileIcon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div data-i18n="Enter web URLs to scrape (one per line):">
|
||||
Enter web URLs to scrape (one per line):
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<div>
|
||||
<strong data-i18n="Enter a video URL to download its transcript.">
|
||||
Enter a video URL or ID to download its transcript.
|
||||
</strong>
|
||||
<div data-i18n="Examples:" class="m-t-1">
|
||||
Examples:
|
||||
</div>
|
||||
<ul class="justifyLeft">
|
||||
<li>https://www.youtube.com/watch?v=jV1vkHv4zq8</li>
|
||||
<li>https://youtu.be/nlLhw1mtCFA</li>
|
||||
<li>TDpxx5UqrVU</li>
|
||||
</ul>
|
||||
<label>
|
||||
Language code (optional 2-letter ISO code):
|
||||
</label>
|
||||
<input type="text" class="text_pole" name="youtubeLanguageCode" placeholder="e.g. en">
|
||||
<label>
|
||||
Video ID:
|
||||
</label>
|
||||
</div>
|
||||
805
web-app/public/scripts/extensions/caption/index.js
Normal file
805
web-app/public/scripts/extensions/caption/index.js
Normal file
@@ -0,0 +1,805 @@
|
||||
import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js';
|
||||
import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';
|
||||
import { appendMediaToMessage, chat_metadata, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
|
||||
import { getMessageTimeStamp } from '../../RossAscends-mods.js';
|
||||
import { SECRET_KEYS, secret_state } from '../../secrets.js';
|
||||
import { getMultimodalCaption } from '../shared.js';
|
||||
import { textgen_types, textgenerationwebui_settings } from '../../textgen-settings.js';
|
||||
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
||||
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
|
||||
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
|
||||
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
|
||||
import { debounce_timeout, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR } from '../../constants.js';
|
||||
export { MODULE_NAME };
|
||||
|
||||
const MODULE_NAME = 'caption';
|
||||
|
||||
const PROMPT_DEFAULT = 'What\'s in this image?';
|
||||
const TEMPLATE_DEFAULT = '[{{user}} sends {{char}} a picture that contains: {{caption}}]';
|
||||
|
||||
/**
|
||||
* Migrates old extension settings to the new format.
|
||||
* Must keep this function for compatibility with old settings.
|
||||
*/
|
||||
function migrateSettings() {
|
||||
if (extension_settings.caption.local !== undefined) {
|
||||
extension_settings.caption.source = extension_settings.caption.local ? 'local' : 'extras';
|
||||
}
|
||||
|
||||
delete extension_settings.caption.local;
|
||||
|
||||
if (!extension_settings.caption.source) {
|
||||
extension_settings.caption.source = 'extras';
|
||||
}
|
||||
|
||||
if (extension_settings.caption.source === 'openai') {
|
||||
extension_settings.caption.source = 'multimodal';
|
||||
extension_settings.caption.multimodal_api = 'openai';
|
||||
extension_settings.caption.multimodal_model = 'gpt-4-turbo';
|
||||
}
|
||||
|
||||
if (!extension_settings.caption.multimodal_api) {
|
||||
extension_settings.caption.multimodal_api = 'openai';
|
||||
}
|
||||
|
||||
if (!extension_settings.caption.multimodal_model) {
|
||||
extension_settings.caption.multimodal_model = 'gpt-4-turbo';
|
||||
}
|
||||
|
||||
if (!extension_settings.caption.prompt) {
|
||||
extension_settings.caption.prompt = PROMPT_DEFAULT;
|
||||
}
|
||||
|
||||
if (!extension_settings.caption.template) {
|
||||
extension_settings.caption.template = TEMPLATE_DEFAULT;
|
||||
}
|
||||
|
||||
if (!extension_settings.caption.show_in_chat) {
|
||||
extension_settings.caption.show_in_chat = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an image icon for the send button.
|
||||
*/
|
||||
async function setImageIcon() {
|
||||
try {
|
||||
const sendButton = $('#send_picture .extensionsMenuExtensionButton');
|
||||
sendButton.addClass('fa-image');
|
||||
sendButton.removeClass('fa-hourglass-half');
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a spinner icon for the send button.
|
||||
*/
|
||||
async function setSpinnerIcon() {
|
||||
try {
|
||||
const sendButton = $('#send_picture .extensionsMenuExtensionButton');
|
||||
sendButton.removeClass('fa-image');
|
||||
sendButton.addClass('fa-hourglass-half');
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a caption with a message template.
|
||||
* @param {string} caption Raw caption
|
||||
* @returns {Promise<string>} Wrapped caption
|
||||
*/
|
||||
async function wrapCaptionTemplate(caption) {
|
||||
let template = extension_settings.caption.template || TEMPLATE_DEFAULT;
|
||||
|
||||
if (!/{{caption}}/i.test(template)) {
|
||||
console.warn('Poka-yoke: Caption template does not contain {{caption}}. Appending it.');
|
||||
template += ' {{caption}}';
|
||||
}
|
||||
|
||||
let messageText = substituteParamsExtended(template, { caption: caption });
|
||||
|
||||
if (extension_settings.caption.refine_mode) {
|
||||
messageText = await Popup.show.input(
|
||||
'Review and edit the generated caption:',
|
||||
'Press "Cancel" to abort the caption sending.',
|
||||
messageText,
|
||||
{ rows: 8, okButton: 'Send' });
|
||||
|
||||
if (!messageText) {
|
||||
throw new Error('User aborted the caption sending.');
|
||||
}
|
||||
}
|
||||
|
||||
return messageText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends caption to an existing message.
|
||||
* @param {ChatMessage} message Message data
|
||||
* @param {number} mediaIndex Index of the image to caption
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function captionExistingMessage(message, mediaIndex) {
|
||||
if (!Array.isArray(message?.extra?.media) || message.extra.media.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaIndex === undefined || isNaN(mediaIndex) || mediaIndex < 0 || mediaIndex >= message.extra.media.length) {
|
||||
mediaIndex = 0;
|
||||
}
|
||||
|
||||
const mediaAttachment = message.extra.media[mediaIndex];
|
||||
|
||||
if (!mediaAttachment || !mediaAttachment.url || mediaAttachment.type === MEDIA_TYPE.AUDIO) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaAttachment.type === MEDIA_TYPE.VIDEO && !isVideoCaptioningAvailable()) {
|
||||
throw new Error('Captioning videos is not supported for the current source.');
|
||||
}
|
||||
|
||||
const imageData = await fetch(mediaAttachment.url);
|
||||
const blob = await imageData.blob();
|
||||
const fileName = mediaAttachment.url.split('/').pop().split('?')[0] || 'image.jpg';
|
||||
const file = new File([blob], fileName, { type: blob.type });
|
||||
const caption = await getCaptionForFile(file, null, true);
|
||||
|
||||
if (!caption) {
|
||||
console.warn('Failed to generate a caption for the image.');
|
||||
return;
|
||||
}
|
||||
|
||||
const wrappedCaption = await wrapCaptionTemplate(caption);
|
||||
|
||||
const messageText = String(message.mes).trim();
|
||||
|
||||
if (!messageText) {
|
||||
message.extra.inline_image = false;
|
||||
message.mes = wrappedCaption;
|
||||
mediaAttachment.title = wrappedCaption;
|
||||
mediaAttachment.captioned = true;
|
||||
} else {
|
||||
message.extra.inline_image = true;
|
||||
mediaAttachment.append_title = true;
|
||||
mediaAttachment.title = wrappedCaption;
|
||||
mediaAttachment.captioned = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a captioned message to the chat.
|
||||
* @param {string} caption Caption text
|
||||
* @param {string} image Image URL
|
||||
* @param {string} mimeType Image MIME type
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sendCaptionedMessage(caption, image, mimeType) {
|
||||
const messageText = await wrapCaptionTemplate(caption);
|
||||
|
||||
const context = getContext();
|
||||
|
||||
/** @type {MediaAttachment} */
|
||||
const mediaAttachment = {
|
||||
url: image,
|
||||
type: MEDIA_TYPE.getFromMime(mimeType) || MEDIA_TYPE.IMAGE,
|
||||
title: messageText,
|
||||
captioned: true,
|
||||
source: MEDIA_SOURCE.CAPTIONED,
|
||||
};
|
||||
/** @type {ChatMessage} */
|
||||
const message = {
|
||||
name: context.name1,
|
||||
is_user: true,
|
||||
send_date: getMessageTimeStamp(),
|
||||
mes: messageText,
|
||||
extra: {
|
||||
media: [mediaAttachment],
|
||||
media_display: MEDIA_DISPLAY.GALLERY,
|
||||
media_index: 0,
|
||||
inline_image: !!extension_settings.caption.show_in_chat,
|
||||
},
|
||||
};
|
||||
chat_metadata['tainted'] = true;
|
||||
context.chat.push(message);
|
||||
const messageId = context.chat.length - 1;
|
||||
await eventSource.emit(event_types.MESSAGE_SENT, messageId);
|
||||
context.addOneMessage(message);
|
||||
await eventSource.emit(event_types.USER_MESSAGE_RENDERED, messageId);
|
||||
await context.saveChat();
|
||||
setTimeout(() => context.scrollOnMediaLoad(), debounce_timeout.short);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a caption for an image using a selected source.
|
||||
* @param {string} base64Img Base64 encoded image without the data:image/...;base64, prefix
|
||||
* @param {string} fileData Base64 encoded image with the data:image/...;base64, prefix
|
||||
* @param {string} externalPrompt Caption prompt
|
||||
* @returns {Promise<{caption: string}>} Generated caption
|
||||
*/
|
||||
async function doCaptionRequest(base64Img, fileData, externalPrompt) {
|
||||
switch (extension_settings.caption.source) {
|
||||
case 'local':
|
||||
return await captionLocal(base64Img);
|
||||
case 'extras':
|
||||
return await captionExtras(base64Img);
|
||||
case 'horde':
|
||||
return await captionHorde(base64Img);
|
||||
case 'multimodal':
|
||||
return await captionMultimodal(fileData, externalPrompt);
|
||||
default:
|
||||
throw new Error('Unknown caption source.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a caption for an image using Extras API.
|
||||
* @param {string} base64Img Base64 encoded image without the data:image/...;base64, prefix
|
||||
* @returns {Promise<{caption: string}>} Generated caption
|
||||
*/
|
||||
async function captionExtras(base64Img) {
|
||||
if (!modules.includes('caption')) {
|
||||
throw new Error('No captioning module is available.');
|
||||
}
|
||||
|
||||
const url = new URL(getApiUrl());
|
||||
url.pathname = '/api/caption';
|
||||
|
||||
const apiResult = await doExtrasFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Bypass-Tunnel-Reminder': 'bypass',
|
||||
},
|
||||
body: JSON.stringify({ image: base64Img }),
|
||||
});
|
||||
|
||||
if (!apiResult.ok) {
|
||||
throw new Error('Failed to caption image via Extras.');
|
||||
}
|
||||
|
||||
const data = await apiResult.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a caption for an image using a local model.
|
||||
* @param {string} base64Img Base64 encoded image without the data:image/...;base64, prefix
|
||||
* @returns {Promise<{caption: string}>} Generated caption
|
||||
*/
|
||||
async function captionLocal(base64Img) {
|
||||
const apiResult = await fetch('/api/extra/caption', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ image: base64Img }),
|
||||
});
|
||||
|
||||
if (!apiResult.ok) {
|
||||
throw new Error('Failed to caption image via local pipeline.');
|
||||
}
|
||||
|
||||
const data = await apiResult.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a caption for an image using a Horde model.
|
||||
* @param {string} base64Img Base64 encoded image without the data:image/...;base64, prefix
|
||||
* @returns {Promise<{caption: string}>} Generated caption
|
||||
*/
|
||||
async function captionHorde(base64Img) {
|
||||
const apiResult = await fetch('/api/horde/caption-image', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ image: base64Img }),
|
||||
});
|
||||
|
||||
if (!apiResult.ok) {
|
||||
throw new Error('Failed to caption image via Horde.');
|
||||
}
|
||||
|
||||
const data = await apiResult.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a caption for an image using a multimodal model.
|
||||
* @param {string} base64Img Base64 encoded image with the data:image/...;base64, prefix
|
||||
* @param {string} externalPrompt Caption prompt
|
||||
* @returns {Promise<{caption: string}>} Generated caption
|
||||
*/
|
||||
async function captionMultimodal(base64Img, externalPrompt) {
|
||||
let prompt = externalPrompt || extension_settings.caption.prompt || PROMPT_DEFAULT;
|
||||
|
||||
if (!externalPrompt && extension_settings.caption.prompt_ask) {
|
||||
const customPrompt = await callGenericPopup('Enter a comment or question:', POPUP_TYPE.INPUT, prompt, { rows: 4 });
|
||||
if (!customPrompt) {
|
||||
throw new Error('User aborted the caption sending.');
|
||||
}
|
||||
prompt = String(customPrompt).trim();
|
||||
}
|
||||
|
||||
const caption = await getMultimodalCaption(base64Img, prompt);
|
||||
return { caption };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the image selection event.
|
||||
* @param {Event} e Input event
|
||||
* @param {string} prompt Caption prompt
|
||||
* @param {boolean} quiet Suppresses sending a message
|
||||
* @returns {Promise<string>} Generated caption
|
||||
*/
|
||||
async function onSelectImage(e, prompt, quiet) {
|
||||
if (!(e.target instanceof HTMLInputElement)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const file = e.target.files[0];
|
||||
const form = e.target.form;
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
form && form.reset();
|
||||
return '';
|
||||
}
|
||||
|
||||
const caption = await getCaptionForFile(file, prompt, quiet);
|
||||
form && form.reset();
|
||||
return caption;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a caption for an image file.
|
||||
* @param {File} file Input file
|
||||
* @param {string} prompt Caption prompt
|
||||
* @param {boolean} quiet Suppresses sending a message
|
||||
* @returns {Promise<string>} Generated caption
|
||||
*/
|
||||
async function getCaptionForFile(file, prompt, quiet) {
|
||||
try {
|
||||
if (file.type.startsWith('video/') && !isVideoCaptioningAvailable()) {
|
||||
throw new Error('Video captioning is not available for the current source.');
|
||||
}
|
||||
|
||||
setSpinnerIcon();
|
||||
const context = getContext();
|
||||
const fileData = await getBase64Async(await ensureImageFormatSupported(file));
|
||||
const extension = getFileExtension(file);
|
||||
const base64Data = fileData.split(',')[1];
|
||||
const { caption } = await doCaptionRequest(base64Data, fileData, prompt);
|
||||
if (!quiet) {
|
||||
const imagePath = await saveBase64AsFile(base64Data, context.name2, '', extension);
|
||||
await sendCaptionedMessage(caption, imagePath, file.type);
|
||||
}
|
||||
return caption;
|
||||
}
|
||||
catch (error) {
|
||||
const errorMessage = error.message || 'Unknown error';
|
||||
toastr.error(errorMessage, 'Failed to caption');
|
||||
console.error(error);
|
||||
return '';
|
||||
}
|
||||
finally {
|
||||
setImageIcon();
|
||||
}
|
||||
}
|
||||
|
||||
function onRefineModeInput() {
|
||||
extension_settings.caption.refine_mode = $('#caption_refine_mode').prop('checked');
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for the /caption command.
|
||||
* @param {object} args Named parameters
|
||||
* @param {string} prompt Caption prompt
|
||||
*/
|
||||
async function captionCommandCallback(args, prompt) {
|
||||
const quiet = isTrueBoolean(args?.quiet);
|
||||
const messageId = args?.mesId ?? args?.id;
|
||||
const index = Number(args?.index ?? 0);
|
||||
|
||||
if (!isNaN(Number(messageId))) {
|
||||
/** @type {ChatMessage} */
|
||||
const message = getContext().chat[messageId];
|
||||
if (Array.isArray(message?.extra?.media) && message.extra.media.length > 0) {
|
||||
try {
|
||||
const mediaAttachment = message.extra.media[index] || message.extra.media[0];
|
||||
if (!mediaAttachment || !mediaAttachment.url) {
|
||||
toastr.error('The specified message does not contain an image.');
|
||||
return '';
|
||||
}
|
||||
if (mediaAttachment.type === MEDIA_TYPE.AUDIO) {
|
||||
toastr.error('The specified media is an audio file. Captioning audio files is not supported.');
|
||||
return '';
|
||||
}
|
||||
if (mediaAttachment.type === MEDIA_TYPE.VIDEO && !isVideoCaptioningAvailable()) {
|
||||
toastr.error('The specified media is a video. Captioning videos is not supported for the current source.');
|
||||
return '';
|
||||
}
|
||||
const fetchResult = await fetch(mediaAttachment.url);
|
||||
const blob = await fetchResult.blob();
|
||||
const fileName = mediaAttachment.url.split('/').pop().split('?')[0] || 'image.jpg';
|
||||
const file = new File([blob], fileName, { type: blob.type });
|
||||
return await getCaptionForFile(file, prompt, quiet);
|
||||
} catch (error) {
|
||||
toastr.error('Failed to get image from the message. Make sure the image is accessible.');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*,video/*';
|
||||
input.onchange = async (e) => {
|
||||
const caption = await onSelectImage(e, prompt, quiet);
|
||||
resolve(caption);
|
||||
};
|
||||
input.oncancel = () => resolve('');
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if video captioning is available for the current source.
|
||||
* @returns {boolean} True if video captioning is supported for the current source.
|
||||
*/
|
||||
function isVideoCaptioningAvailable() {
|
||||
if (extension_settings.caption.source !== 'multimodal') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ['google', 'vertexai', 'zai'].includes(extension_settings.caption.multimodal_api);
|
||||
}
|
||||
|
||||
jQuery(async function () {
|
||||
function addSendPictureButton() {
|
||||
const sendButton = $(`
|
||||
<div id="send_picture" class="list-group-item flex-container flexGap5">
|
||||
<div class="fa-solid fa-image extensionsMenuExtensionButton"></div>
|
||||
<span data-i18n="Generate Caption">Generate Caption</span>
|
||||
</div>`);
|
||||
|
||||
$('#caption_wand_container').append(sendButton);
|
||||
$(sendButton).on('click', () => {
|
||||
const hasCaptionModule = (() => {
|
||||
const settings = extension_settings.caption;
|
||||
|
||||
// Handle non-multimodal sources
|
||||
if (settings.source === 'extras' && modules.includes('caption')) return true;
|
||||
if (settings.source === 'local' || settings.source === 'horde') return true;
|
||||
|
||||
// Handle multimodal sources
|
||||
if (settings.source === 'multimodal') {
|
||||
const api = settings.multimodal_api;
|
||||
const altEndpointEnabled = settings.alt_endpoint_enabled;
|
||||
const altEndpointUrl = settings.alt_endpoint_url;
|
||||
|
||||
// APIs that support reverse proxy
|
||||
const reverseProxyApis = {
|
||||
'openai': SECRET_KEYS.OPENAI,
|
||||
'mistral': SECRET_KEYS.MISTRALAI,
|
||||
'google': SECRET_KEYS.MAKERSUITE,
|
||||
'vertexai': SECRET_KEYS.VERTEXAI,
|
||||
'anthropic': SECRET_KEYS.CLAUDE,
|
||||
'xai': SECRET_KEYS.XAI,
|
||||
};
|
||||
|
||||
if (reverseProxyApis[api]) {
|
||||
if (secret_state[reverseProxyApis[api]] || settings.allow_reverse_proxy) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const chatCompletionApis = {
|
||||
'openrouter': SECRET_KEYS.OPENROUTER,
|
||||
'groq': SECRET_KEYS.GROQ,
|
||||
'cohere': SECRET_KEYS.COHERE,
|
||||
'aimlapi': SECRET_KEYS.AIMLAPI,
|
||||
'moonshot': SECRET_KEYS.MOONSHOT,
|
||||
'nanogpt': SECRET_KEYS.NANOGPT,
|
||||
'chutes': SECRET_KEYS.CHUTES,
|
||||
'electronhub': SECRET_KEYS.ELECTRONHUB,
|
||||
'zai': SECRET_KEYS.ZAI,
|
||||
};
|
||||
|
||||
if (chatCompletionApis[api] && secret_state[chatCompletionApis[api]]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const textCompletionApis = {
|
||||
'ollama': textgen_types.OLLAMA,
|
||||
'llamacpp': textgen_types.LLAMACPP,
|
||||
'ooba': textgen_types.OOBA,
|
||||
'koboldcpp': textgen_types.KOBOLDCPP,
|
||||
'vllm': textgen_types.VLLM,
|
||||
};
|
||||
|
||||
if (textCompletionApis[api] && altEndpointEnabled && altEndpointUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (textCompletionApis[api] && !altEndpointEnabled && textgenerationwebui_settings.server_urls[textCompletionApis[api]]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Custom API doesn't need additional checks
|
||||
if (api === 'custom' || api === 'pollinations') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
|
||||
if (!hasCaptionModule) {
|
||||
toastr.error('Choose other captioning source in the extension settings.', 'Captioning is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
$('#img_file').trigger('click');
|
||||
});
|
||||
}
|
||||
function addPictureSendForm() {
|
||||
const imgInput = document.createElement('input');
|
||||
imgInput.type = 'file';
|
||||
imgInput.id = 'img_file';
|
||||
imgInput.accept = 'image/*,video/*';
|
||||
imgInput.hidden = true;
|
||||
imgInput.addEventListener('change', (e) => onSelectImage(e, '', false));
|
||||
const imgForm = document.createElement('form');
|
||||
imgForm.id = 'img_form';
|
||||
imgForm.appendChild(imgInput);
|
||||
imgForm.hidden = true;
|
||||
$('#form_sheld').append(imgForm);
|
||||
}
|
||||
async function switchMultimodalBlocks() {
|
||||
await addRemoteEndpointModels();
|
||||
const isMultimodal = extension_settings.caption.source === 'multimodal';
|
||||
if (!extension_settings.caption.multimodal_model) {
|
||||
const dropdown = $('#caption_multimodal_model');
|
||||
const options = dropdown.find(`option[data-type="${extension_settings.caption.multimodal_api}"]`);
|
||||
extension_settings.caption.multimodal_model = String(options.first().val());
|
||||
}
|
||||
$('#caption_multimodal_block').toggle(isMultimodal);
|
||||
$('#caption_prompt_block').toggle(isMultimodal);
|
||||
$('#caption_multimodal_api').val(extension_settings.caption.multimodal_api);
|
||||
$('#caption_multimodal_model').val(extension_settings.caption.multimodal_model);
|
||||
$('#caption_multimodal_block [data-type]').each(function () {
|
||||
const type = $(this).data('type');
|
||||
const types = type.split(',');
|
||||
$(this).toggle(types.includes(extension_settings.caption.multimodal_api));
|
||||
});
|
||||
}
|
||||
async function addSettings() {
|
||||
const html = await renderExtensionTemplateAsync('caption', 'settings', { TEMPLATE_DEFAULT, PROMPT_DEFAULT });
|
||||
$('#caption_container').append(html);
|
||||
}
|
||||
|
||||
async function addRemoteEndpointModels() {
|
||||
async function processEndpoint(api, url) {
|
||||
const dropdown = document.getElementById('caption_multimodal_model');
|
||||
if (!(dropdown instanceof HTMLSelectElement)) {
|
||||
return;
|
||||
}
|
||||
if (extension_settings.caption.source !== 'multimodal' || extension_settings.caption.multimodal_api !== api) {
|
||||
return;
|
||||
}
|
||||
const options = Array.from(dropdown.options);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders({ omitContentType: true }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const modelIds = await response.json();
|
||||
if (Array.isArray(modelIds) && modelIds.length > 0) {
|
||||
modelIds.sort().forEach((modelId) => {
|
||||
if (!modelId || typeof modelId !== 'string' || options.some(o => o.value === modelId)) {
|
||||
return;
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = modelId;
|
||||
option.textContent = modelId;
|
||||
option.dataset.type = api;
|
||||
dropdown.add(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await processEndpoint('openrouter', '/api/openrouter/models/multimodal');
|
||||
await processEndpoint('aimlapi', '/api/backends/chat-completions/multimodal-models/aimlapi');
|
||||
await processEndpoint('pollinations', '/api/backends/chat-completions/multimodal-models/pollinations');
|
||||
await processEndpoint('nanogpt', '/api/backends/chat-completions/multimodal-models/nanogpt');
|
||||
await processEndpoint('chutes', '/api/backends/chat-completions/multimodal-models/chutes');
|
||||
await processEndpoint('electronhub', '/api/backends/chat-completions/multimodal-models/electronhub');
|
||||
await processEndpoint('mistral', '/api/backends/chat-completions/multimodal-models/mistral');
|
||||
await processEndpoint('xai', '/api/backends/chat-completions/multimodal-models/xai');
|
||||
}
|
||||
|
||||
await addSettings();
|
||||
addPictureSendForm();
|
||||
addSendPictureButton();
|
||||
setImageIcon();
|
||||
migrateSettings();
|
||||
await switchMultimodalBlocks();
|
||||
|
||||
$('#caption_refine_mode').prop('checked', !!(extension_settings.caption.refine_mode));
|
||||
$('#caption_allow_reverse_proxy').prop('checked', !!(extension_settings.caption.allow_reverse_proxy));
|
||||
$('#caption_prompt_ask').prop('checked', !!(extension_settings.caption.prompt_ask));
|
||||
$('#caption_auto_mode').prop('checked', !!(extension_settings.caption.auto_mode));
|
||||
$('#caption_source').val(extension_settings.caption.source);
|
||||
$('#caption_prompt').val(extension_settings.caption.prompt);
|
||||
$('#caption_template').val(extension_settings.caption.template);
|
||||
$('#caption_refine_mode').on('input', onRefineModeInput);
|
||||
$('#caption_source').on('change', async () => {
|
||||
extension_settings.caption.source = String($('#caption_source').val());
|
||||
await switchMultimodalBlocks();
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_prompt').on('input', () => {
|
||||
extension_settings.caption.prompt = String($('#caption_prompt').val());
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_template').on('input', () => {
|
||||
extension_settings.caption.template = String($('#caption_template').val());
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_allow_reverse_proxy').on('input', () => {
|
||||
extension_settings.caption.allow_reverse_proxy = $('#caption_allow_reverse_proxy').prop('checked');
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_prompt_ask').on('input', () => {
|
||||
extension_settings.caption.prompt_ask = $('#caption_prompt_ask').prop('checked');
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_auto_mode').on('input', () => {
|
||||
extension_settings.caption.auto_mode = !!$('#caption_auto_mode').prop('checked');
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_ollama_pull').on('click', (e) => {
|
||||
const selectedModel = extension_settings.caption.multimodal_model;
|
||||
const staticModels = { 'ollama_current': textgenerationwebui_settings.ollama_model, 'ollama_custom': extension_settings.caption.ollama_custom_model };
|
||||
const presetModel = staticModels[selectedModel] || selectedModel;
|
||||
e.preventDefault();
|
||||
$('#ollama_download_model').trigger('click');
|
||||
$('.popup .popup-input').val(presetModel);
|
||||
});
|
||||
$('#caption_multimodal_api').on('change', async () => {
|
||||
const api = String($('#caption_multimodal_api').val());
|
||||
extension_settings.caption.multimodal_api = api;
|
||||
extension_settings.caption.multimodal_model = '';
|
||||
await switchMultimodalBlocks();
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_multimodal_model').on('change', () => {
|
||||
extension_settings.caption.multimodal_model = String($('#caption_multimodal_model').val());
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_altEndpoint_url').val(extension_settings.caption.alt_endpoint_url).on('input', () => {
|
||||
extension_settings.caption.alt_endpoint_url = String($('#caption_altEndpoint_url').val());
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_altEndpoint_enabled').prop('checked', !!(extension_settings.caption.alt_endpoint_enabled)).on('input', () => {
|
||||
extension_settings.caption.alt_endpoint_enabled = !!$('#caption_altEndpoint_enabled').prop('checked');
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_show_in_chat').prop('checked', !!(extension_settings.caption.show_in_chat)).on('input', () => {
|
||||
extension_settings.caption.show_in_chat = !!$('#caption_show_in_chat').prop('checked');
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_ollama_custom_model').val(extension_settings.caption.ollama_custom_model || '').on('input', () => {
|
||||
extension_settings.caption.ollama_custom_model = String($('#caption_ollama_custom_model').val()).trim();
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$('#caption_refresh_models').on('click', async () => {
|
||||
extension_settings.caption.multimodal_model = '';
|
||||
await switchMultimodalBlocks();
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
const onMessageEvent = async (/** @type {number} */ messageId) => {
|
||||
if (!extension_settings.caption.auto_mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = getContext().chat[messageId];
|
||||
if (Array.isArray(message?.extra?.media) && message.extra.media.length > 0) {
|
||||
for (let mediaIndex = 0; mediaIndex < message.extra.media.length; mediaIndex++) {
|
||||
const mediaAttachment = message.extra.media[mediaIndex];
|
||||
if (mediaAttachment.type === MEDIA_TYPE.VIDEO && !isVideoCaptioningAvailable()) {
|
||||
continue;
|
||||
}
|
||||
if (mediaAttachment.type === MEDIA_TYPE.AUDIO) {
|
||||
continue;
|
||||
}
|
||||
// Skip already captioned images and non-uploaded (generated, etc.) images
|
||||
if (mediaAttachment.source !== MEDIA_SOURCE.UPLOAD || mediaAttachment.captioned) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await captionExistingMessage(message, mediaIndex);
|
||||
} catch (e) {
|
||||
console.error(`Auto-captioning failed for message ID ${messageId}, media index ${mediaIndex}`, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.on(event_types.MESSAGE_SENT, onMessageEvent);
|
||||
eventSource.on(event_types.MESSAGE_FILE_EMBEDDED, onMessageEvent);
|
||||
|
||||
$(document).on('click', '.mes_img_caption', async function () {
|
||||
const animationClass = 'fa-fade';
|
||||
const messageBlock = $(this).closest('.mes');
|
||||
const mediaContainer = $(this).closest('.mes_media_container');
|
||||
const messageMedia = mediaContainer.find('.mes_img, .mes_video');
|
||||
if (messageMedia.hasClass(animationClass)) return;
|
||||
messageMedia.addClass(animationClass);
|
||||
try {
|
||||
const messageId = Number(messageBlock.attr('mesid'));
|
||||
const mediaIndex = Number(mediaContainer.attr('data-index'));
|
||||
const data = getContext().chat[messageId];
|
||||
await captionExistingMessage(data, mediaIndex);
|
||||
appendMediaToMessage(data, messageBlock, SCROLL_BEHAVIOR.KEEP);
|
||||
await saveChatConditional();
|
||||
} catch (e) {
|
||||
console.error('Message image recaption failed', e);
|
||||
toastr.error(e.message || 'Unknown error', 'Failed to caption');
|
||||
} finally {
|
||||
messageMedia.removeClass(animationClass);
|
||||
}
|
||||
});
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'caption',
|
||||
callback: captionCommandCallback,
|
||||
returns: 'caption',
|
||||
namedArgumentList: [
|
||||
new SlashCommandNamedArgument(
|
||||
'quiet', 'suppress sending a captioned message', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
|
||||
),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'mesId',
|
||||
description: 'get image from a message with this ID',
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
enumProvider: commonEnumProviders.messages(),
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'index',
|
||||
description: 'index of the image in the message to caption (starting from 0)',
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
enumProvider: commonEnumProviders.messageMedia(),
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
new SlashCommandArgument(
|
||||
'prompt', [ARGUMENT_TYPE.STRING], false,
|
||||
),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
Caption an image with an optional prompt and passes the caption down the pipe.
|
||||
</div>
|
||||
<div>
|
||||
Only multimodal sources support custom prompts.
|
||||
</div>
|
||||
<div>
|
||||
Provide a message ID to get an image from a message instead of uploading one.
|
||||
</div>
|
||||
<div>
|
||||
Set the "quiet" argument to true to suppress sending a captioned message, default: false.
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
|
||||
document.body.classList.add('caption');
|
||||
});
|
||||
13
web-app/public/scripts/extensions/caption/manifest.json
Normal file
13
web-app/public/scripts/extensions/caption/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"display_name": "Image Captioning",
|
||||
"loading_order": 4,
|
||||
"requires": [],
|
||||
"optional": [
|
||||
"caption"
|
||||
],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "Cohee#1207",
|
||||
"version": "1.0.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
||||
238
web-app/public/scripts/extensions/caption/settings.html
Normal file
238
web-app/public/scripts/extensions/caption/settings.html
Normal file
@@ -0,0 +1,238 @@
|
||||
|
||||
<div class="caption_settings">
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<b data-i18n="Image Captioning">Image Captioning</b>
|
||||
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
|
||||
</div>
|
||||
<div class="inline-drawer-content">
|
||||
<label for="caption_source" data-i18n="Source">Source</label>
|
||||
<select id="caption_source" class="text_pole">
|
||||
<option value="local" data-i18n="Local">Local</option>
|
||||
<option value="multimodal" data-i18n="Multimodal">Multimodal</option>
|
||||
<option value="extras" data-i18n="Extras">Extras (deprecated)</option>
|
||||
<option value="horde" data-i18n="Horde">Horde</option>
|
||||
</select>
|
||||
<div id="caption_multimodal_block" class="flex-container wide100p">
|
||||
<div class="flex1 flex-container flexFlowColumn flexNoGap">
|
||||
<label for="caption_multimodal_api" data-i18n="API">API</label>
|
||||
<select id="caption_multimodal_api" class="flex1 text_pole">
|
||||
<option value="aimlapi">AI/ML API</option>
|
||||
<option value="chutes">Chutes</option>
|
||||
<option value="anthropic">Claude</option>
|
||||
<option value="cohere">Cohere</option>
|
||||
<option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>
|
||||
<option value="electronhub">Electron Hub</option>
|
||||
<option value="google">Google AI Studio</option>
|
||||
<option value="vertexai">Google Vertex AI</option>
|
||||
<option value="groq">Groq</option>
|
||||
<option value="koboldcpp">KoboldCpp</option>
|
||||
<option value="llamacpp">llama.cpp</option>
|
||||
<option value="mistral">MistralAI</option>
|
||||
<option value="moonshot">Moonshot AI</option>
|
||||
<option value="nanogpt">NanoGPT</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="ooba" data-i18n="Text Generation WebUI (oobabooga)">Text Generation WebUI (oobabooga)</option>
|
||||
<option value="pollinations">Pollinations</option>
|
||||
<option value="vllm">vLLM</option>
|
||||
<option value="xai">xAI (Grok)</option>
|
||||
<option value="zai">Z.AI (GLM)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex1 flex-container flexFlowColumn flexNoGap">
|
||||
<label for="caption_multimodal_model" class="flex-container justifySpaceBetween">
|
||||
<span data-i18n="Model">Model</span>
|
||||
<div id="caption_refresh_models" class="right_menu_button margin0 padding0" title="Refresh model list" data-i18n="[title]Refresh model list">
|
||||
<i class="fa-solid fa-sync"></i>
|
||||
</div>
|
||||
</label>
|
||||
<select id="caption_multimodal_model" class="flex1 text_pole">
|
||||
<!-- AI/ML API, OpenRouter, Pollinations, NanoGPT, Mistral, xAI are added externally by JavaScript -->
|
||||
<option data-type="cohere" value="c4ai-aya-vision-8b">c4ai-aya-vision-8b</option>
|
||||
<option data-type="cohere" value="c4ai-aya-vision-32b">c4ai-aya-vision-32b</option>
|
||||
<option data-type="cohere" value="command-a-vision-07-2025">command-a-vision-07-2025</option>
|
||||
<option data-type="moonshot" value="moonshot-v1-8k-vision-preview">moonshot-v1-8k-vision-preview</option>
|
||||
<option data-type="moonshot" value="moonshot-v1-32k-vision-preview">moonshot-v1-32k-vision-preview</option>
|
||||
<option data-type="moonshot" value="moonshot-v1-128k-vision-preview">moonshot-v1-128k-vision-preview</option>
|
||||
<option data-type="openai" value="gpt-5.2">gpt-5.2</option>
|
||||
<option data-type="openai" value="gpt-5.2-2025-12-11">gpt-5.2-2025-12-11</option>
|
||||
<option data-type="openai" value="gpt-5.2-chat-latest">gpt-5.2-chat-latest</option>
|
||||
<option data-type="openai" value="gpt-5.1">gpt-5.1</option>
|
||||
<option data-type="openai" value="gpt-5.1-2025-11-13">gpt-5.1-2025-11-13</option>
|
||||
<option data-type="openai" value="gpt-5.1-chat-latest">gpt-5.1-chat-latest</option>
|
||||
<option data-type="openai" value="gpt-5">gpt-5</option>
|
||||
<option data-type="openai" value="gpt-5-2025-08-07">gpt-5-2025-08-07</option>
|
||||
<option data-type="openai" value="gpt-5-chat-latest">gpt-5-chat-latest</option>
|
||||
<option data-type="openai" value="gpt-5-mini">gpt-5-mini</option>
|
||||
<option data-type="openai" value="gpt-5-mini-2025-08-07">gpt-5-mini-2025-08-07</option>
|
||||
<option data-type="openai" value="gpt-5-nano">gpt-5-nano</option>
|
||||
<option data-type="openai" value="gpt-5-nano-2025-08-07">gpt-5-nano-2025-08-07</option>
|
||||
<option data-type="openai" value="gpt-4.1">gpt-4.1</option>
|
||||
<option data-type="openai" value="gpt-4.1-2025-04-14">gpt-4.1-2025-04-14</option>
|
||||
<option data-type="openai" value="gpt-4.1-mini">gpt-4.1-mini</option>
|
||||
<option data-type="openai" value="gpt-4.1-mini-2025-04-14">gpt-4.1-mini-2025-04-14</option>
|
||||
<option data-type="openai" value="gpt-4.1-nano">gpt-4.1-nano</option>
|
||||
<option data-type="openai" value="gpt-4.1-nano-2025-04-14">gpt-4.1-nano-2025-04-14</option>
|
||||
<option data-type="openai" value="gpt-4-vision-preview">gpt-4-vision-preview</option>
|
||||
<option data-type="openai" value="gpt-4-turbo">gpt-4-turbo</option>
|
||||
<option data-type="openai" value="gpt-4o">gpt-4o</option>
|
||||
<option data-type="openai" value="gpt-4o-mini">gpt-4o-mini</option>
|
||||
<option data-type="openai" value="gpt-4o-mini-2024-07-18">gpt-4o-mini-2024-07-18</option>
|
||||
<option data-type="openai" value="chatgpt-4o-latest">chatgpt-4o-latest</option>
|
||||
<option data-type="openai" value="o1">o1</option>
|
||||
<option data-type="openai" value="o1-2024-12-17">o1-2024-12-17</option>
|
||||
<option data-type="openai" value="o3">o3</option>
|
||||
<option data-type="openai" value="o3-2025-04-16">o3-2025-04-16</option>
|
||||
<option data-type="openai" value="o4-mini">o4-mini</option>
|
||||
<option data-type="openai" value="o4-mini-2025-04-16">o4-mini-2025-04-16</option>
|
||||
<option data-type="openai" value="gpt-4.5-preview">gpt-4.5-preview</option>
|
||||
<option data-type="openai" value="gpt-4.5-preview-2025-02-27">gpt-4.5-preview-2025-02-27</option>
|
||||
<option data-type="anthropic" value="claude-opus-4-5">claude-opus-4-5</option>
|
||||
<option data-type="anthropic" value="claude-opus-4-5-20251101">claude-opus-4-5-20251101</option>
|
||||
<option data-type="anthropic" value="claude-sonnet-4-5">claude-sonnet-4-5</option>
|
||||
<option data-type="anthropic" value="claude-sonnet-4-5-20250929">claude-sonnet-4-5-20250929</option>
|
||||
<option data-type="anthropic" value="claude-haiku-4-5">claude-haiku-4-5</option>
|
||||
<option data-type="anthropic" value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
|
||||
<option data-type="anthropic" value="claude-opus-4-1">claude-opus-4-1</option>
|
||||
<option data-type="anthropic" value="claude-opus-4-1-20250805">claude-opus-4-1-20250805</option>
|
||||
<option data-type="anthropic" value="claude-opus-4-0">claude-opus-4-0</option>
|
||||
<option data-type="anthropic" value="claude-opus-4-20250514">claude-opus-4-20250514</option>
|
||||
<option data-type="anthropic" value="claude-sonnet-4-0">claude-sonnet-4-0</option>
|
||||
<option data-type="anthropic" value="claude-sonnet-4-20250514">claude-sonnet-4-20250514</option>
|
||||
<option data-type="anthropic" value="claude-3-7-sonnet-latest">claude-3-7-sonnet-latest</option>
|
||||
<option data-type="anthropic" value="claude-3-7-sonnet-20250219">claude-3-7-sonnet-20250219</option>
|
||||
<option data-type="anthropic" value="claude-3-5-sonnet-latest">claude-3-5-sonnet-latest</option>
|
||||
<option data-type="anthropic" value="claude-3-5-sonnet-20241022">claude-3-5-sonnet-20241022</option>
|
||||
<option data-type="anthropic" value="claude-3-5-sonnet-20240620">claude-3-5-sonnet-20240620</option>
|
||||
<option data-type="anthropic" value="claude-3-5-haiku-latest">claude-3-5-haiku-latest</option>
|
||||
<option data-type="anthropic" value="claude-3-5-haiku-20241022">claude-3-5-haiku-20241022</option>
|
||||
<option data-type="anthropic" value="claude-3-opus-20240229">claude-3-opus-20240229</option>
|
||||
<option data-type="anthropic" value="claude-3-haiku-20240307">claude-3-haiku-20240307</option>
|
||||
<option data-type="google" value="gemini-3-pro-preview">gemini-3-pro-preview</option>
|
||||
<option data-type="google" value="gemini-3-pro-image-preview">gemini-3-pro-image-preview</option>
|
||||
<option data-type="google" value="gemini-3-flash-preview">gemini-3-flash-preview</option>
|
||||
<option data-type="google" value="gemini-2.5-pro">gemini-2.5-pro</option>
|
||||
<option data-type="google" value="gemini-2.5-pro-preview-06-05">gemini-2.5-pro-preview-06-05</option>
|
||||
<option data-type="google" value="gemini-2.5-pro-preview-05-06">gemini-2.5-pro-preview-05-06</option>
|
||||
<option data-type="google" value="gemini-2.5-pro-preview-03-25">gemini-2.5-pro-preview-03-25</option>
|
||||
<option data-type="google" value="gemini-2.5-flash">gemini-2.5-flash</option>
|
||||
<option data-type="google" value="gemini-2.5-flash-preview-09-2025">gemini-2.5-flash-preview-09-2025</option>
|
||||
<option data-type="google" value="gemini-2.5-flash-preview-05-20">gemini-2.5-flash-preview-05-20</option>
|
||||
<option data-type="google" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
|
||||
<option data-type="google" value="gemini-2.5-flash-lite-preview-09-2025">gemini-2.5-flash-lite-preview-09-2025</option>
|
||||
<option data-type="google" value="gemini-2.5-flash-lite-preview-06-17">gemini-2.5-flash-lite-preview-06-17</option>
|
||||
<option data-type="google" value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
|
||||
<option data-type="google" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
|
||||
<option data-type="google" value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-02-05 → 2.5-exp-03-25</option>
|
||||
<option data-type="google" value="gemini-2.0-pro-exp">gemini-2.0-pro-exp → 2.5-exp-03-25</option>
|
||||
<option data-type="google" value="gemini-exp-1206">gemini-exp-1206 → 2.5-exp-03-25</option>
|
||||
<option data-type="google" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
|
||||
<option data-type="google" value="gemini-2.0-flash-exp-image-generation">gemini-2.0-flash-exp-image-generation</option>
|
||||
<option data-type="google" value="gemini-2.0-flash-exp">gemini-2.0-flash-exp</option>
|
||||
<option data-type="google" value="gemini-2.0-flash">gemini-2.0-flash</option>
|
||||
<option data-type="google" value="gemini-2.0-flash-thinking-exp-01-21">gemini-2.0-flash-thinking-exp-01-21 → 2.5-flash-preview-5-20</option>
|
||||
<option data-type="google" value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219 → 2.5-flash-preview-05-20</option>
|
||||
<option data-type="google" value="gemini-2.0-flash-thinking-exp">gemini-2.0-flash-thinking-exp → 2.5-flash-preview-05-20</option>
|
||||
<option data-type="google" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
|
||||
<option data-type="google" value="gemini-2.0-flash-lite-preview-02-05">gemini-2.0-flash-lite-preview-02-05</option>
|
||||
<option data-type="google" value="gemini-2.0-flash-lite-preview">gemini-2.0-flash-lite-preview</option>
|
||||
<option data-type="google" value="learnlm-2.0-flash-experimental">learnlm-2.0-flash-experimental</option>
|
||||
<option data-type="google" value="gemini-robotics-er-1.5-preview">gemini-robotics-er-1.5-preview</option>
|
||||
<option data-type="vertexai" value="gemini-3-pro-preview">gemini-3-pro-preview</option>
|
||||
<option data-type="vertexai" value="gemini-3-pro-image-preview">gemini-3-pro-image-preview</option>
|
||||
<option data-type="vertexai" value="gemini-3-flash-preview">gemini-3-flash-preview</option>
|
||||
<option data-type="vertexai" value="gemini-2.5-pro">gemini-2.5-pro</option>
|
||||
<option data-type="vertexai" value="gemini-2.5-flash">gemini-2.5-flash</option>
|
||||
<option data-type="vertexai" value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
|
||||
<option data-type="vertexai" value="gemini-2.5-flash-image">gemini-2.5-flash-image</option>
|
||||
<option data-type="vertexai" value="gemini-2.5-flash-image-preview">gemini-2.5-flash-image-preview</option>
|
||||
<option data-type="vertexai" value="gemini-2.0-flash-001">gemini-2.0-flash-001</option>
|
||||
<option data-type="vertexai" value="gemini-2.0-flash-lite-001">gemini-2.0-flash-lite-001</option>
|
||||
<option data-type="groq" value="meta-llama/llama-4-scout-17b-16e-instruct">meta-llama/llama-4-scout-17b-16e-instruct</option>
|
||||
<option data-type="groq" value="meta-llama/llama-4-maverick-17b-128e-instruct">meta-llama/llama-4-maverick-17b-128e-instruct</option>
|
||||
<option data-type="ollama" value="ollama_current" data-i18n="currently_selected">[Currently selected]</option>
|
||||
<option data-type="ollama" value="ollama_custom" data-i18n="[Custom model]">[Custom model]</option>
|
||||
<option data-type="ollama" value="bakllava">bakllava</option>
|
||||
<option data-type="ollama" value="llava">llava</option>
|
||||
<option data-type="ollama" value="llava-llama3">llava-llama3</option>
|
||||
<option data-type="ollama" value="llava-phi3">llava-phi3</option>
|
||||
<option data-type="ollama" value="moondream">moondream</option>
|
||||
<option data-type="ollama" value="gemma3">gemma3</option>
|
||||
<option data-type="ollama" value="minicpm-v">minicpm-v</option>
|
||||
<option data-type="ollama" value="qwen2.5vl">qwen2.5vl</option>
|
||||
<option data-type="ollama" value="granite3.2-vision">granite3.2-vision</option>
|
||||
<option data-type="ollama" value="mistral-small3.1">mistral-small3.1</option>
|
||||
<option data-type="ollama" value="mistral-small3.2">mistral-small3.2</option>
|
||||
<option data-type="ollama" value="llama3.2-vision">llama3.2-vision</option>
|
||||
<option data-type="ollama" value="llama4">llama4</option>
|
||||
<option data-type="zai" value="glm-4.6v">glm-4.6v</option>
|
||||
<option data-type="zai" value="glm-4.6v-flashx">glm-4.6v-flashx</option>
|
||||
<option data-type="zai" value="glm-4.6v-flash">glm-4.6v-flash</option>
|
||||
<option data-type="zai" value="glm-4.5v">glm-4.5v</option>
|
||||
<option data-type="zai" value="autoglm-phone-multilingual">autoglm-phone-multilingual</option>
|
||||
<option data-type="llamacpp" value="llamacpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
|
||||
<option data-type="ooba" value="ooba_current" data-i18n="currently_loaded">[Currently loaded]</option>
|
||||
<option data-type="koboldcpp" value="koboldcpp_current" data-i18n="currently_loaded">[Currently loaded]</option>
|
||||
<option data-type="vllm" value="vllm_current" data-i18n="currently_selected">[Currently selected]</option>
|
||||
<option data-type="custom" value="custom_current" data-i18n="currently_selected">[Currently selected]</option>
|
||||
</select>
|
||||
</div>
|
||||
<div data-type="ollama">
|
||||
<div>
|
||||
The model must be downloaded first! Do it with the <code>ollama pull</code> command or <a href="#" id="caption_ollama_pull">click here</a>.
|
||||
</div>
|
||||
<div class="marginTop5">
|
||||
<label for="caption_ollama_custom_model">
|
||||
<span data-i18n="Custom Model Tag">Custom Model Tag</span>
|
||||
<small data-i18n="(for [Custom model] option)">(for [Custom model] option)</small>
|
||||
</label>
|
||||
<input id="caption_ollama_custom_model" class="text_pole" type="text" placeholder="e.g. gemma3:latest" />
|
||||
</div>
|
||||
</div>
|
||||
<label data-type="openai,anthropic,google,vertexai,mistral,xai" class="checkbox_label flexBasis100p" for="caption_allow_reverse_proxy" title="Allow using reverse proxy if defined and valid.">
|
||||
<input id="caption_allow_reverse_proxy" type="checkbox" class="checkbox">
|
||||
<span data-i18n="Allow reverse proxy">Allow reverse proxy</span>
|
||||
</label>
|
||||
<div class="flexBasis100p marginBot10">
|
||||
<small><b data-i18n="Hint:">Hint:</b> <span data-i18n="Set your API keys and endpoints in the 'API Connections' tab first.">Set your API keys and endpoints in the 'API Connections' tab first.</span></small>
|
||||
</div>
|
||||
<div data-type="koboldcpp,ollama,vllm,llamacpp,ooba" class="flex-container flexFlowColumn wide100p">
|
||||
<label for="caption_altEndpoint_enabled" class="checkbox_label">
|
||||
<input id="caption_altEndpoint_enabled" type="checkbox">
|
||||
<span data-i18n="Use secondary URL">Use secondary URL</span>
|
||||
</label>
|
||||
<label for="caption_altEndpoint_url" data-i18n="Secondary captioning endpoint URL">
|
||||
Secondary captioning endpoint URL
|
||||
</label>
|
||||
<input id="caption_altEndpoint_url" class="text_pole" type="text" placeholder="e.g. http://localhost:5001" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="caption_prompt_block">
|
||||
<label for="caption_prompt" data-i18n="Caption Prompt">Caption Prompt</label>
|
||||
<textarea id="caption_prompt" class="text_pole textarea_compact autoSetHeight" rows="1" placeholder="< Use default >">{{PROMPT_DEFAULT}}</textarea>
|
||||
<label class="checkbox_label margin-bot-10px" for="caption_prompt_ask" title="Ask for a custom prompt every time an image is captioned.">
|
||||
<input id="caption_prompt_ask" type="checkbox" class="checkbox">
|
||||
<span data-i18n="Ask every time">Ask every time</span>
|
||||
</label>
|
||||
</div>
|
||||
<label for="caption_template"><span data-i18n="Message Template">Message Template</span> <small><span data-i18n="(use _space">(use </span> <code>{{caption}}</code> <span data-i18n="macro)">macro)</span></small></label>
|
||||
<textarea id="caption_template" class="text_pole textarea_compact autoSetHeight" rows="2" placeholder="< Use default >">{{TEMPLATE_DEFAULT}}</textarea>
|
||||
<label class="checkbox_label" for="caption_auto_mode">
|
||||
<input id="caption_auto_mode" type="checkbox" class="checkbox">
|
||||
<span data-i18n="Automatically caption images">Automatically caption images</span>
|
||||
<i class="fa-solid fa-info-circle" title="Automatically caption images when they are pasted into the chat or attached to messages."></i>
|
||||
</label>
|
||||
<label class="checkbox_label" for="caption_refine_mode">
|
||||
<input id="caption_refine_mode" type="checkbox" class="checkbox">
|
||||
<span data-i18n="Edit captions before saving">Edit captions before saving</span>
|
||||
</label>
|
||||
<label class="checkbox_label" for="caption_show_in_chat">
|
||||
<input id="caption_show_in_chat" type="checkbox" class="checkbox">
|
||||
<span data-i18n="Show captions in chat">Show captions in chat</span>
|
||||
</label>
|
||||
<div class="margin-bot-10px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
3
web-app/public/scripts/extensions/caption/style.css
Normal file
3
web-app/public/scripts/extensions/caption/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
#img_form {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<div>
|
||||
<h3 data-i18n="Included settings:">Included settings:</h3>
|
||||
<div class="justifyLeft flex-container flexFlowColumn flexNoGap">
|
||||
{{#each settings}}
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" value="{{@key}}" name="exclude"{{#if this}} checked{{/if}}>
|
||||
<span data-i18n="{{@key}}">{{@key}}</span>
|
||||
</label>
|
||||
{{/each}}
|
||||
</div>
|
||||
<h3 data-i18n="Profile name:">Profile name:</h3>
|
||||
</div>
|
||||
827
web-app/public/scripts/extensions/connection-manager/index.js
Normal file
827
web-app/public/scripts/extensions/connection-manager/index.js
Normal file
@@ -0,0 +1,827 @@
|
||||
import { DOMPurify, Fuse } from '../../../lib.js';
|
||||
|
||||
import { event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
|
||||
import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
|
||||
import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
|
||||
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
|
||||
import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
|
||||
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
|
||||
import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';
|
||||
import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
|
||||
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
||||
import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
|
||||
import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
|
||||
import { t } from '../../i18n.js';
|
||||
import { getSecretLabelById } from '../../secrets.js';
|
||||
|
||||
const MODULE_NAME = 'connection-manager';
|
||||
const NONE = '<None>';
|
||||
const EMPTY = '<Empty>';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
profiles: [],
|
||||
selectedProfile: null,
|
||||
};
|
||||
|
||||
// Commands that can record an empty value into the profile
|
||||
const ALLOW_EMPTY = [
|
||||
'stop-strings',
|
||||
'start-reply-with',
|
||||
];
|
||||
|
||||
const CC_COMMANDS = [
|
||||
'api',
|
||||
'preset',
|
||||
// Do not fix; CC needs to set the API twice because it could be overridden by the preset
|
||||
'api',
|
||||
'api-url',
|
||||
'model',
|
||||
'proxy',
|
||||
'stop-strings',
|
||||
'start-reply-with',
|
||||
'reasoning-template',
|
||||
'prompt-post-processing',
|
||||
'secret-id',
|
||||
'regex-preset',
|
||||
];
|
||||
|
||||
const TC_COMMANDS = [
|
||||
'api',
|
||||
'preset',
|
||||
'api-url',
|
||||
'model',
|
||||
'sysprompt',
|
||||
'sysprompt-state',
|
||||
'instruct',
|
||||
'context',
|
||||
'instruct-state',
|
||||
'tokenizer',
|
||||
'stop-strings',
|
||||
'start-reply-with',
|
||||
'reasoning-template',
|
||||
'secret-id',
|
||||
'regex-preset',
|
||||
];
|
||||
|
||||
const FANCY_NAMES = {
|
||||
'api': 'API',
|
||||
'api-url': 'Server URL',
|
||||
'preset': 'Settings Preset',
|
||||
'model': 'Model',
|
||||
'proxy': 'Proxy Preset',
|
||||
'sysprompt-state': 'Use System Prompt',
|
||||
'sysprompt': 'System Prompt Name',
|
||||
'instruct-state': 'Instruct Mode',
|
||||
'instruct': 'Instruct Template',
|
||||
'context': 'Context Template',
|
||||
'tokenizer': 'Tokenizer',
|
||||
'stop-strings': 'Custom Stopping Strings',
|
||||
'start-reply-with': 'Start Reply With',
|
||||
'reasoning-template': 'Reasoning Template',
|
||||
'prompt-post-processing': 'Prompt Post-Processing',
|
||||
'secret-id': 'Secret',
|
||||
'regex-preset': 'Regex Preset',
|
||||
};
|
||||
|
||||
/**
|
||||
* A wrapper for the connection manager spinner.
|
||||
*/
|
||||
class ConnectionManagerSpinner {
|
||||
/**
|
||||
* @type {AbortController[]}
|
||||
*/
|
||||
static abortControllers = [];
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
spinnerElement;
|
||||
|
||||
/** @type {AbortController} */
|
||||
abortController = new AbortController();
|
||||
|
||||
constructor() {
|
||||
// @ts-ignore
|
||||
this.spinnerElement = document.getElementById('connection_profile_spinner');
|
||||
this.abortController = new AbortController();
|
||||
}
|
||||
|
||||
start() {
|
||||
ConnectionManagerSpinner.abortControllers.push(this.abortController);
|
||||
this.spinnerElement.classList.remove('hidden');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.spinnerElement.classList.add('hidden');
|
||||
}
|
||||
|
||||
isAborted() {
|
||||
return this.abortController.signal.aborted;
|
||||
}
|
||||
|
||||
static abort() {
|
||||
for (const controller of ConnectionManagerSpinner.abortControllers) {
|
||||
controller.abort();
|
||||
}
|
||||
ConnectionManagerSpinner.abortControllers = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get named arguments for the command callback.
|
||||
* @param {object} [args] Additional named arguments
|
||||
* @param {string} [args.force] Whether to force setting the value
|
||||
* @returns {object} Named arguments
|
||||
*/
|
||||
function getNamedArguments(args = {}) {
|
||||
// None of the commands here use underscored args, but better safe than sorry
|
||||
return {
|
||||
_scope: new SlashCommandScope(),
|
||||
_abortController: new SlashCommandAbortController(),
|
||||
_debugController: new SlashCommandDebugController(),
|
||||
_parserFlags: {},
|
||||
_hasUnnamedArgument: false,
|
||||
quiet: 'true',
|
||||
...args,
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {() => SlashCommandEnumValue[]} */
|
||||
const profilesProvider = () => [
|
||||
new SlashCommandEnumValue(NONE),
|
||||
...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
|
||||
];
|
||||
|
||||
/**
|
||||
* @typedef {Object} ConnectionProfile
|
||||
* @property {string} id Unique identifier
|
||||
* @property {string} mode Mode of the connection profile
|
||||
* @property {string} [name] Name of the connection profile
|
||||
* @property {string} [api] API
|
||||
* @property {string} [preset] Settings Preset
|
||||
* @property {string} [model] Model
|
||||
* @property {string} [proxy] Proxy Preset
|
||||
* @property {string} [instruct] Instruct Template
|
||||
* @property {string} [context] Context Template
|
||||
* @property {string} [instruct-state] Instruct Mode
|
||||
* @property {string} [tokenizer] Tokenizer
|
||||
* @property {string} [stop-strings] Custom Stopping Strings
|
||||
* @property {string} [start-reply-with] Start Reply With
|
||||
* @property {string} [reasoning-template] Reasoning Template
|
||||
* @property {string} [prompt-post-processing] Prompt Post-Processing
|
||||
* @property {string} [sysprompt] System Prompt Name
|
||||
* @property {string} [sysprompt-state] Use System Prompt
|
||||
* @property {string} [api-url] Server URL
|
||||
* @property {string} [secret-id] Secret ID
|
||||
* @property {string} [regex-preset] Regex Preset ID
|
||||
* @property {string[]} [exclude] Commands to exclude
|
||||
*/
|
||||
|
||||
/**
|
||||
* Finds the best match for the search value.
|
||||
* @param {string} value Search value
|
||||
* @returns {ConnectionProfile|null} Best match or null
|
||||
*/
|
||||
function findProfileByName(value) {
|
||||
// Try to find exact match
|
||||
const profile = extension_settings.connectionManager.profiles.find(p => p.name === value);
|
||||
|
||||
if (profile) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
// Try to find fuzzy match
|
||||
const fuse = new Fuse(extension_settings.connectionManager.profiles, { keys: ['name'] });
|
||||
const results = fuse.search(value);
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bestMatch = results[0];
|
||||
return bestMatch.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the connection profile from the commands.
|
||||
* @param {string} mode Mode of the connection profile
|
||||
* @param {ConnectionProfile} profile Connection profile
|
||||
* @param {boolean} [cleanUp] Whether to clean up the profile
|
||||
*/
|
||||
async function readProfileFromCommands(mode, profile, cleanUp = false) {
|
||||
const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
|
||||
const opposingCommands = mode === 'cc' ? TC_COMMANDS : CC_COMMANDS;
|
||||
const excludeList = Array.isArray(profile.exclude) ? profile.exclude : [];
|
||||
for (const command of commands) {
|
||||
try {
|
||||
if (excludeList.includes(command)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowEmpty = ALLOW_EMPTY.includes(command);
|
||||
const args = getNamedArguments();
|
||||
const result = await SlashCommandParser.commands[command].callback(args, '');
|
||||
if (result || (allowEmpty && result === '')) {
|
||||
profile[command] = result;
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to execute command: ${command}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanUp) {
|
||||
for (const command of commands) {
|
||||
if (command.endsWith('-state') && profile[command] === 'false') {
|
||||
delete profile[command.replace('-state', '')];
|
||||
}
|
||||
}
|
||||
for (const command of opposingCommands) {
|
||||
if (commands.includes(command)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
delete profile[command];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new connection profile.
|
||||
* @param {string} [forceName] Name of the connection profile
|
||||
* @returns {Promise<ConnectionProfile>} Created connection profile
|
||||
*/
|
||||
async function createConnectionProfile(forceName = null) {
|
||||
const mode = main_api === 'openai' ? 'cc' : 'tc';
|
||||
const id = uuidv4();
|
||||
/** @type {ConnectionProfile} */
|
||||
const profile = {
|
||||
id,
|
||||
mode,
|
||||
exclude: [],
|
||||
};
|
||||
|
||||
await readProfileFromCommands(mode, profile);
|
||||
|
||||
const profileForDisplay = makeFancyProfile(profile);
|
||||
const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'profile', { profile: profileForDisplay }));
|
||||
template.find('input[name="exclude"]').on('input', function () {
|
||||
const fancyName = String($(this).val());
|
||||
const keyName = Object.entries(FANCY_NAMES).find(x => x[1] === fancyName)?.[0];
|
||||
if (!keyName) {
|
||||
console.warn('Key not found for fancy name:', fancyName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(profile.exclude)) {
|
||||
profile.exclude = [];
|
||||
}
|
||||
|
||||
const excludeState = !$(this).prop('checked');
|
||||
if (excludeState) {
|
||||
profile.exclude.push(keyName);
|
||||
} else {
|
||||
const index = profile.exclude.indexOf(keyName);
|
||||
index !== -1 && profile.exclude.splice(index, 1);
|
||||
}
|
||||
});
|
||||
const isNameTaken = (n) => extension_settings.connectionManager.profiles.some(p => p.name === n);
|
||||
const suggestedName = getUniqueName(collapseSpaces(`${profile.api ?? ''} ${profile.model ?? ''} - ${profile.preset ?? ''}`), isNameTaken);
|
||||
let name = forceName ?? await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName);
|
||||
// If it's cancelled, it will be false
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
name = DOMPurify.sanitize(String(name));
|
||||
if (!name) {
|
||||
toastr.error('Name cannot be empty.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNameTaken(name) || name === NONE) {
|
||||
toastr.error('A profile with the same name already exists.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(profile.exclude)) {
|
||||
for (const command of profile.exclude) {
|
||||
delete profile[command];
|
||||
}
|
||||
}
|
||||
|
||||
profile.name = String(name);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the selected connection profile.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function deleteConnectionProfile() {
|
||||
const selectedProfile = extension_settings.connectionManager.selectedProfile;
|
||||
if (!selectedProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = extension_settings.connectionManager.profiles.findIndex(p => p.id === selectedProfile);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = extension_settings.connectionManager.profiles[index];
|
||||
const name = profile.name;
|
||||
const confirm = await Popup.show.confirm(t`Are you sure you want to delete the selected profile?`, name);
|
||||
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
extension_settings.connectionManager.profiles.splice(index, 1);
|
||||
extension_settings.connectionManager.selectedProfile = null;
|
||||
saveSettingsDebounced();
|
||||
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_DELETED, profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the connection profile for display.
|
||||
* @param {ConnectionProfile} profile Connection profile
|
||||
* @returns {Object} Fancy profile
|
||||
*/
|
||||
function makeFancyProfile(profile) {
|
||||
return Object.entries(FANCY_NAMES).reduce((acc, [key, value]) => {
|
||||
const allowEmpty = ALLOW_EMPTY.includes(key);
|
||||
if (!profile[key]) {
|
||||
if (profile[key] === '' && allowEmpty) {
|
||||
acc[value] = EMPTY;
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// UUID is not very useful in the UI, so we replace it with a label (if available)
|
||||
if (key === 'secret-id') {
|
||||
const label = getSecretLabelById(profile[key]);
|
||||
if (label) {
|
||||
acc[value] = label;
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'regex-preset') {
|
||||
const label = extension_settings.regex_presets?.find(p => p.id === profile[key])?.name;
|
||||
if (label) {
|
||||
acc[value] = label;
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
acc[value] = profile[key];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the connection profile.
|
||||
* @param {ConnectionProfile} profile Connection profile
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function applyConnectionProfile(profile) {
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any ongoing profile application
|
||||
ConnectionManagerSpinner.abort();
|
||||
|
||||
const mode = profile.mode;
|
||||
const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
|
||||
const spinner = new ConnectionManagerSpinner();
|
||||
spinner.start();
|
||||
|
||||
for (const command of commands) {
|
||||
if (spinner.isAborted()) {
|
||||
throw new Error('Profile application aborted');
|
||||
}
|
||||
|
||||
const argument = profile[command];
|
||||
const allowEmpty = ALLOW_EMPTY.includes(command);
|
||||
if (!argument && !(allowEmpty && argument === '')) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const args = getNamedArguments(allowEmpty ? { force: 'true' } : {});
|
||||
await SlashCommandParser.commands[command].callback(args, argument);
|
||||
} catch (error) {
|
||||
console.error(`Failed to execute command: ${command} ${argument}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the selected connection profile.
|
||||
* @param {ConnectionProfile} profile Connection profile
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updateConnectionProfile(profile) {
|
||||
profile.mode = main_api === 'openai' ? 'cc' : 'tc';
|
||||
await readProfileFromCommands(profile.mode, profile, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the connection profile details.
|
||||
* @param {HTMLSelectElement} profiles Select element containing connection profiles
|
||||
*/
|
||||
function renderConnectionProfiles(profiles) {
|
||||
profiles.innerHTML = '';
|
||||
const noneOption = document.createElement('option');
|
||||
|
||||
noneOption.value = '';
|
||||
noneOption.textContent = NONE;
|
||||
noneOption.selected = !extension_settings.connectionManager.selectedProfile;
|
||||
profiles.appendChild(noneOption);
|
||||
|
||||
for (const profile of extension_settings.connectionManager.profiles.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const option = document.createElement('option');
|
||||
option.value = profile.id;
|
||||
option.textContent = profile.name;
|
||||
option.selected = profile.id === extension_settings.connectionManager.selectedProfile;
|
||||
profiles.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content of the details element.
|
||||
* @param {HTMLElement} detailsContent Content element of the details
|
||||
*/
|
||||
async function renderDetailsContent(detailsContent) {
|
||||
detailsContent.innerHTML = '';
|
||||
if (detailsContent.classList.contains('hidden')) {
|
||||
return;
|
||||
}
|
||||
const selectedProfile = extension_settings.connectionManager.selectedProfile;
|
||||
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
|
||||
if (profile) {
|
||||
const profileForDisplay = makeFancyProfile(profile);
|
||||
const templateParams = { profile: profileForDisplay };
|
||||
if (Array.isArray(profile.exclude) && profile.exclude.length > 0) {
|
||||
templateParams.omitted = profile.exclude.map(e => FANCY_NAMES[e]).join(', ');
|
||||
}
|
||||
const template = await renderExtensionTemplateAsync(MODULE_NAME, 'view', templateParams);
|
||||
detailsContent.innerHTML = template;
|
||||
} else {
|
||||
detailsContent.textContent = t`No profile selected`;
|
||||
}
|
||||
}
|
||||
|
||||
(async function () {
|
||||
extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
|
||||
|
||||
for (const key of Object.keys(DEFAULT_SETTINGS)) {
|
||||
if (extension_settings.connectionManager[key] === undefined) {
|
||||
extension_settings.connectionManager[key] = DEFAULT_SETTINGS[key];
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.getElementById('rm_api_block');
|
||||
const settings = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
|
||||
container.insertAdjacentHTML('afterbegin', settings);
|
||||
|
||||
/** @type {HTMLSelectElement} */
|
||||
// @ts-ignore
|
||||
const profiles = document.getElementById('connection_profiles');
|
||||
renderConnectionProfiles(profiles);
|
||||
|
||||
function toggleProfileSpecificButtons() {
|
||||
const profileId = extension_settings.connectionManager.selectedProfile;
|
||||
const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile'];
|
||||
profileSpecificButtons.forEach(id => document.getElementById(id).classList.toggle('disabled', !profileId));
|
||||
}
|
||||
toggleProfileSpecificButtons();
|
||||
|
||||
profiles.addEventListener('change', async function () {
|
||||
const selectedProfile = profiles.selectedOptions[0];
|
||||
if (!selectedProfile) {
|
||||
// Safety net for preventing the command getting stuck
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileId = selectedProfile.value;
|
||||
extension_settings.connectionManager.selectedProfile = profileId;
|
||||
saveSettingsDebounced();
|
||||
await renderDetailsContent(detailsContent);
|
||||
|
||||
toggleProfileSpecificButtons();
|
||||
|
||||
// None option selected
|
||||
if (!profileId) {
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = extension_settings.connectionManager.profiles.find(p => p.id === profileId);
|
||||
|
||||
if (!profile) {
|
||||
console.log(`Profile not found: ${profileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await applyConnectionProfile(profile);
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
|
||||
});
|
||||
|
||||
const reloadButton = document.getElementById('reload_connection_profile');
|
||||
reloadButton.addEventListener('click', async () => {
|
||||
const selectedProfile = extension_settings.connectionManager.selectedProfile;
|
||||
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
|
||||
if (!profile) {
|
||||
console.log('No profile selected');
|
||||
return;
|
||||
}
|
||||
await applyConnectionProfile(profile);
|
||||
await renderDetailsContent(detailsContent);
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
|
||||
toastr.success('Connection profile reloaded', '', { timeOut: 1500 });
|
||||
});
|
||||
|
||||
const createButton = document.getElementById('create_connection_profile');
|
||||
createButton.addEventListener('click', async () => {
|
||||
const profile = await createConnectionProfile();
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
extension_settings.connectionManager.profiles.push(profile);
|
||||
extension_settings.connectionManager.selectedProfile = profile.id;
|
||||
saveSettingsDebounced();
|
||||
renderConnectionProfiles(profiles);
|
||||
await renderDetailsContent(detailsContent);
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
|
||||
});
|
||||
|
||||
const updateButton = document.getElementById('update_connection_profile');
|
||||
updateButton.addEventListener('click', async () => {
|
||||
const selectedProfile = extension_settings.connectionManager.selectedProfile;
|
||||
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
|
||||
if (!profile) {
|
||||
console.log('No profile selected');
|
||||
return;
|
||||
}
|
||||
const oldProfile = structuredClone(profile);
|
||||
await updateConnectionProfile(profile);
|
||||
await renderDetailsContent(detailsContent);
|
||||
saveSettingsDebounced();
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
|
||||
toastr.success('Connection profile updated', '', { timeOut: 1500 });
|
||||
});
|
||||
|
||||
const deleteButton = document.getElementById('delete_connection_profile');
|
||||
deleteButton.addEventListener('click', async () => {
|
||||
await deleteConnectionProfile();
|
||||
renderConnectionProfiles(profiles);
|
||||
await renderDetailsContent(detailsContent);
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
|
||||
});
|
||||
|
||||
const editButton = document.getElementById('edit_connection_profile');
|
||||
editButton.addEventListener('click', async () => {
|
||||
const selectedProfile = extension_settings.connectionManager.selectedProfile;
|
||||
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
|
||||
if (!profile) {
|
||||
console.log('No profile selected');
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(profile.exclude)) {
|
||||
profile.exclude = [];
|
||||
}
|
||||
|
||||
let saveChanges = false;
|
||||
const sortByViewOrder = (a, b) => Object.keys(FANCY_NAMES).indexOf(a) - Object.keys(FANCY_NAMES).indexOf(b);
|
||||
const commands = profile.mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
|
||||
const settings = commands.slice().sort(sortByViewOrder).reduce((acc, command) => {
|
||||
const fancyName = FANCY_NAMES[command];
|
||||
acc[fancyName] = !profile.exclude.includes(command);
|
||||
return acc;
|
||||
}, {});
|
||||
const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'edit', { name: profile.name, settings }));
|
||||
let newName = await callGenericPopup(template, POPUP_TYPE.INPUT, profile.name, {
|
||||
customButtons: [{
|
||||
text: t`Save and Update`,
|
||||
classes: ['popup-button-ok'],
|
||||
result: POPUP_RESULT.AFFIRMATIVE,
|
||||
action: () => {
|
||||
saveChanges = true;
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
// If it's cancelled, it will be false
|
||||
if (!newName) {
|
||||
return;
|
||||
}
|
||||
newName = DOMPurify.sanitize(String(newName));
|
||||
if (!newName) {
|
||||
toastr.error('Name cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile.name !== newName && extension_settings.connectionManager.profiles.some(p => p.name === newName)) {
|
||||
toastr.error('A profile with the same name already exists.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newExcludeList = template.find('input[name="exclude"]:not(:checked)').map(function () {
|
||||
return Object.entries(FANCY_NAMES).find(x => x[1] === String($(this).val()))?.[0];
|
||||
}).get();
|
||||
|
||||
const oldProfile = structuredClone(profile);
|
||||
if (newExcludeList.length !== profile.exclude.length || !newExcludeList.every(e => profile.exclude.includes(e))) {
|
||||
profile.exclude = newExcludeList;
|
||||
for (const command of newExcludeList) {
|
||||
delete profile[command];
|
||||
}
|
||||
if (saveChanges) {
|
||||
await updateConnectionProfile(profile);
|
||||
} else {
|
||||
toastr.info('Press "Update" to record them into the profile.', 'Included settings list updated');
|
||||
}
|
||||
}
|
||||
|
||||
if (profile.name !== newName) {
|
||||
toastr.success('Connection profile renamed.');
|
||||
profile.name = newName;
|
||||
}
|
||||
|
||||
saveSettingsDebounced();
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
|
||||
renderConnectionProfiles(profiles);
|
||||
await renderDetailsContent(detailsContent);
|
||||
});
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const viewDetails = document.getElementById('view_connection_profile');
|
||||
const detailsContent = document.getElementById('connection_profile_details_content');
|
||||
viewDetails.addEventListener('click', async () => {
|
||||
viewDetails.classList.toggle('active');
|
||||
detailsContent.classList.toggle('hidden');
|
||||
await renderDetailsContent(detailsContent);
|
||||
});
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'profile',
|
||||
helpString: 'Switch to a connection profile or return the name of the current profile in no argument is provided. Use <code><None></code> to switch to no profile.',
|
||||
returns: 'name of the profile',
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Name of the connection profile',
|
||||
enumProvider: profilesProvider,
|
||||
isRequired: false,
|
||||
}),
|
||||
],
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'await',
|
||||
description: 'Wait for the connection profile to be applied before returning.',
|
||||
isRequired: false,
|
||||
typeList: [ARGUMENT_TYPE.BOOLEAN],
|
||||
defaultValue: 'true',
|
||||
enumList: commonEnumProviders.boolean('trueFalse')(),
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'timeout',
|
||||
description: 'Maximum time to wait for the API connection to be established, in milliseconds. Set to 0 to disable. Only applies when await=true.',
|
||||
isRequired: false,
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
defaultValue: '2000',
|
||||
}),
|
||||
],
|
||||
callback: async (args, value) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
const selectedProfile = extension_settings.connectionManager.selectedProfile;
|
||||
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
|
||||
if (!profile) {
|
||||
return NONE;
|
||||
}
|
||||
return profile.name;
|
||||
}
|
||||
|
||||
if (value === NONE) {
|
||||
profiles.selectedIndex = 0;
|
||||
profiles.dispatchEvent(new Event('change'));
|
||||
return NONE;
|
||||
}
|
||||
|
||||
const profile = findProfileByName(value);
|
||||
|
||||
if (!profile) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const shouldAwait = !isFalseBoolean(String(args?.await));
|
||||
const awaitPromise = new Promise((resolve) => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
|
||||
|
||||
profiles.selectedIndex = Array.from(profiles.options).findIndex(o => o.value === profile.id);
|
||||
profiles.dispatchEvent(new Event('change'));
|
||||
|
||||
if (shouldAwait) {
|
||||
await awaitPromise;
|
||||
|
||||
// We should also await the connection to be established
|
||||
const parsedTimeout = parseInt(args?.timeout?.toString());
|
||||
const timeout = !isNaN(parsedTimeout) ? Math.max(0, parsedTimeout) : 2000;
|
||||
if (timeout > 0) {
|
||||
await waitUntilCondition(() => online_status !== 'no_connection', timeout, 100, { rejectOnTimeout: false });
|
||||
}
|
||||
}
|
||||
|
||||
return profile.name;
|
||||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'profile-list',
|
||||
helpString: 'List all connection profile names.',
|
||||
returns: 'list of profile names',
|
||||
callback: () => JSON.stringify(extension_settings.connectionManager.profiles.map(p => p.name)),
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'profile-create',
|
||||
returns: 'name of the new profile',
|
||||
helpString: 'Create a new connection profile using the current settings.',
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'name of the new connection profile',
|
||||
isRequired: true,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
}),
|
||||
],
|
||||
callback: async (_args, name) => {
|
||||
if (!name || typeof name !== 'string') {
|
||||
toastr.warning('Please provide a name for the new connection profile.');
|
||||
return '';
|
||||
}
|
||||
const profile = await createConnectionProfile(name);
|
||||
if (!profile) {
|
||||
return '';
|
||||
}
|
||||
extension_settings.connectionManager.profiles.push(profile);
|
||||
extension_settings.connectionManager.selectedProfile = profile.id;
|
||||
saveSettingsDebounced();
|
||||
renderConnectionProfiles(profiles);
|
||||
await renderDetailsContent(detailsContent);
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
|
||||
return profile.name;
|
||||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'profile-update',
|
||||
helpString: 'Update the selected connection profile.',
|
||||
callback: async () => {
|
||||
const selectedProfile = extension_settings.connectionManager.selectedProfile;
|
||||
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
|
||||
if (!profile) {
|
||||
toastr.warning('No profile selected.');
|
||||
return '';
|
||||
}
|
||||
const oldProfile = structuredClone(profile);
|
||||
await updateConnectionProfile(profile);
|
||||
await renderDetailsContent(detailsContent);
|
||||
saveSettingsDebounced();
|
||||
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
|
||||
return profile.name;
|
||||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'profile-get',
|
||||
helpString: 'Get the details of the connection profile. Returns the selected profile if no argument is provided.',
|
||||
returns: 'object of the selected profile',
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'Name of the connection profile',
|
||||
enumProvider: profilesProvider,
|
||||
isRequired: false,
|
||||
}),
|
||||
],
|
||||
callback: async (_args, value) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
const selectedProfile = extension_settings.connectionManager.selectedProfile;
|
||||
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
|
||||
if (!profile) {
|
||||
return '';
|
||||
}
|
||||
return JSON.stringify(profile);
|
||||
}
|
||||
|
||||
const profile = findProfileByName(value);
|
||||
if (!profile) {
|
||||
return '';
|
||||
}
|
||||
return JSON.stringify(profile);
|
||||
},
|
||||
}));
|
||||
})();
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"display_name": "Connection Profiles",
|
||||
"loading_order": 1,
|
||||
"requires": [],
|
||||
"optional": [],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "Cohee1207",
|
||||
"version": "1.0.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<div>
|
||||
<h2 data-i18n="Creating a Connection Profile">
|
||||
Creating a Connection Profile
|
||||
</h2>
|
||||
<div class="justifyLeft flex-container flexFlowColumn flexNoGap">
|
||||
{{#each profile}}
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" value="{{@key}}" name="exclude" checked>
|
||||
<span><strong data-i18n="{{@key}}">{{@key}}:</strong> {{this}}</span>
|
||||
</label>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="marginTop5">
|
||||
<small>
|
||||
<b data-i18n="Hint:">Hint:</b>
|
||||
<i data-i18n="Click on the setting name to omit it from the profile.">Click on the setting name to omit it from the profile.</i>
|
||||
</small>
|
||||
</div>
|
||||
<h3 data-i18n="Enter a name:">
|
||||
Enter a name:
|
||||
</h3>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="wide100p">
|
||||
<div class="flex-container alignItemsBaseline">
|
||||
<h3 class="margin0">
|
||||
<span data-i18n="Connection Profile">Connection Profile</span>
|
||||
<a href="https://docs.sillytavern.app/usage/core-concepts/connection-profiles" target="_blank" class="notes-link">
|
||||
<span class="fa-solid fa-circle-question note-link-span"></span>
|
||||
</a>
|
||||
</h3>
|
||||
<i id="connection_profile_spinner" class="fa-solid fa-spinner fa-spin hidden"></i>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<select class="text_pole flex1" id="connection_profiles"></select>
|
||||
<i id="view_connection_profile" class="menu_button fa-solid fa-info-circle" title="View connection profile details" data-i18n="[title]View connection profile details"></i>
|
||||
<i id="create_connection_profile" class="menu_button fa-solid fa-file-circle-plus" title="Create a new connection profile" data-i18n="[title]Create a new connection profile"></i>
|
||||
<i id="update_connection_profile" class="menu_button fa-solid fa-save" title="Update a connection profile" data-i18n="[title]Update a connection profile"></i>
|
||||
<i id="edit_connection_profile" class="menu_button fa-solid fa-pencil" title="Edit a connection profile" data-i18n="[title]Edit a connection profile"></i>
|
||||
<i id="reload_connection_profile" class="menu_button fa-solid fa-recycle" title="Reload a connection profile" data-i18n="[title]Reload a connection profile"></i>
|
||||
<i id="delete_connection_profile" class="menu_button fa-solid fa-trash-can" title="Delete a connection profile" data-i18n="[title]Delete a connection profile"></i>
|
||||
</div>
|
||||
<div id="connection_profile_details_content" class="hidden"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
#connection_profile_details_content {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
#connection_profile_details_content ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#connection_profile_spinner {
|
||||
margin-left: 5px;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<ul>
|
||||
{{#each profile}}
|
||||
<li><strong data-i18n="{{@key}}">{{@key}}:</strong> {{this}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#if omitted}}
|
||||
<div class="margin5">
|
||||
<strong data-i18n="Omitted Settings:">Omitted Settings:</strong> <span>{{omitted}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -0,0 +1,14 @@
|
||||
<h3>
|
||||
Enter a name for the custom expression:
|
||||
</h3>
|
||||
<h4>
|
||||
Requirements:
|
||||
</h4>
|
||||
<ol class="justifyLeft">
|
||||
<li>
|
||||
The name must be unique and not already in use by the default expression.
|
||||
</li>
|
||||
<li>
|
||||
The name must contain only letters, numbers, dashes and underscores. Don't include any file extensions.
|
||||
</li>
|
||||
</ol>
|
||||
2527
web-app/public/scripts/extensions/expressions/index.js
Normal file
2527
web-app/public/scripts/extensions/expressions/index.js
Normal file
File diff suppressed because it is too large
Load Diff
21
web-app/public/scripts/extensions/expressions/list-item.html
Normal file
21
web-app/public/scripts/extensions/expressions/list-item.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{{#each images}}
|
||||
<div class="expression_list_item interactable" data-expression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}">
|
||||
<div class="expression_list_buttons">
|
||||
<div class="menu_button expression_list_upload" title="Upload image">
|
||||
<i class="fa-solid fa-upload"></i>
|
||||
</div>
|
||||
<div class="menu_button expression_list_delete" title="Delete image">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="expression_list_title">
|
||||
<span>{{../expression}}</span>
|
||||
{{#if ../isCustom}}
|
||||
<small class="expression_list_custom">(custom)</small>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="expression_list_image_container" title="{{this.title}}">
|
||||
<img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
13
web-app/public/scripts/extensions/expressions/manifest.json
Normal file
13
web-app/public/scripts/extensions/expressions/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"display_name": "Character Expressions",
|
||||
"loading_order": 6,
|
||||
"requires": [],
|
||||
"optional": [
|
||||
"classify"
|
||||
],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "Cohee#1207",
|
||||
"version": "1.0.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<h3>
|
||||
Are you sure you want to remove the expression <tt>"{{expression}}"</tt>?
|
||||
</h3>
|
||||
<div>
|
||||
Uploaded images will not be deleted, but will no longer be used by the extension.
|
||||
</div>
|
||||
<br>
|
||||
122
web-app/public/scripts/extensions/expressions/settings.html
Normal file
122
web-app/public/scripts/extensions/expressions/settings.html
Normal file
@@ -0,0 +1,122 @@
|
||||
<div class="expression_settings">
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<b data-i18n="Character Expressions">Character Expressions</b>
|
||||
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
|
||||
</div>
|
||||
|
||||
<div class="inline-drawer-content">
|
||||
<label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings." data-i18n="[title]Use the selected API from Chat Translation extension settings.">
|
||||
<input id="expression_translate" type="checkbox">
|
||||
<span data-i18n="Translate text to English before classification">Translate text to English before classification</span>
|
||||
</label>
|
||||
<label class="checkbox_label" for="expressions_allow_multiple" title="A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected." data-i18n="[title]A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.">
|
||||
<input id="expressions_allow_multiple" type="checkbox">
|
||||
<span data-i18n="Allow multiple sprites per expression">Allow multiple sprites per expression</span>
|
||||
</label>
|
||||
<label class="checkbox_label" for="expressions_reroll_if_same" title="If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned." data-i18n="[title]If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.">
|
||||
<input id="expressions_reroll_if_same" type="checkbox">
|
||||
<span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span>
|
||||
</label>
|
||||
<div class="expression_api_block m-b-1 m-t-1">
|
||||
<label for="expression_api" data-i18n="Classifier API">Classifier API</label>
|
||||
<small data-i18n="Select the API for classifying expressions.">Select the API for classifying expressions.</small>
|
||||
<select id="expression_api" class="flex1 margin0">
|
||||
<option value="99" data-i18n="[ None ]">[ None ]</option>
|
||||
<option value="0" data-i18n="Local">Local</option>
|
||||
<option value="1" data-i18n="Extras">Extras (deprecated)</option>
|
||||
<option value="2" data-i18n="Main API">Main API</option>
|
||||
<option value="3" data-i18n="WebLLM Extension">WebLLM Extension</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="expression_llm_prompt_block m-b-1 m-t-1">
|
||||
<label class="checkbox_label" for="expressions_filter_available" title="When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them." data-i18n="[title]When using LLM or WebLLM classifier, only show and use expressions that have sprites assigned to them.">
|
||||
<input id="expressions_filter_available" type="checkbox">
|
||||
<span data-i18n="Filter expressions for available sprites">Filter expressions for available sprites</span>
|
||||
</label>
|
||||
<label for="expression_llm_prompt" class="title_restorable m-t-1">
|
||||
<span data-i18n="LLM Prompt">LLM Prompt</span>
|
||||
<div id="expression_llm_prompt_restore" title="Restore default value" class="right_menu_button">
|
||||
<i class="fa-solid fa-clock-rotate-left fa-sm"></i>
|
||||
</div>
|
||||
</label>
|
||||
<small data-i18n="Used in addition to JSON schemas and function calling.">Used in addition to JSON schemas and function calling.</small>
|
||||
<textarea id="expression_llm_prompt" type="text" class="text_pole textarea_compact autoSetHeight" rows="2" placeholder="Use {{labels}} special macro."></textarea>
|
||||
</div>
|
||||
<div class="expression_prompt_type_block flex-container flexFlowColumn">
|
||||
<div data-i18n="LLM Prompt Strategy" class="title_restorable">
|
||||
LLM Prompt Strategy
|
||||
</div>
|
||||
<label for="expression_prompt_raw" class="checkbox_label">
|
||||
<input id="expression_prompt_raw" type="radio" name="expression_prompt_type" value="raw">
|
||||
<span data-i18n="Limited Context">Limited Context</span>
|
||||
</label>
|
||||
<label for="expression_prompt_full" class="checkbox_label">
|
||||
<input id="expression_prompt_full" type="radio" name="expression_prompt_type" value="full">
|
||||
<span data-i18n="Full Context">Full Context</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="expression_fallback_block m-b-1 m-t-1">
|
||||
<label for="expression_fallback" data-i18n="Default / Fallback Expression">Default / Fallback Expression</label>
|
||||
<small data-i18n="Set the default and fallback expression being used when no matching expression is found.">Set the default and fallback expression being used when no matching expression is found.</small>
|
||||
<select id="expression_fallback" class="flex1 margin0"></select>
|
||||
</div>
|
||||
<div class="expression_custom_block m-b-1 m-t-1">
|
||||
<label for="expression_custom" data-i18n="Custom Expressions">Custom Expressions</label>
|
||||
<small><span data-i18n="Can be set manually or with an _space">Can be set manually or with an </span><tt>/emote</tt><span data-i18n="space_ slash command."> slash command.</span></small>
|
||||
<div class="flex-container">
|
||||
<select id="expression_custom" class="flex1 margin0"></select>
|
||||
<i id="expression_custom_add" class="menu_button fa-solid fa-plus margin0" title="Add"></i>
|
||||
<i id="expression_custom_remove" class="menu_button fa-solid fa-xmark margin0" title="Remove"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div id="no_chat_expressions" data-i18n="Open a chat to see the character expressions.">
|
||||
Open a chat to see the character expressions.
|
||||
</div>
|
||||
<div id="open_chat_expressions">
|
||||
<div class="offline_mode">
|
||||
<small data-i18n="You are in offline mode. Click on the image below to set the expression.">You are in offline mode. Click on the image below to set the expression.</small>
|
||||
</div>
|
||||
<label for="expression_override" data-i18n="Sprite Folder Override">Sprite Folder Override</label>
|
||||
<small><span data-i18n="Use a forward slash to specify a subfolder. Example: _space">Use a forward slash to specify a subfolder. Example: </span><tt>Bob/formal</tt></small>
|
||||
<div class="flex-container flexnowrap">
|
||||
<input id="expression_override" type="text" class="text_pole" placeholder="Override folder name" />
|
||||
<input id="expression_override_button" class="menu_button" type="submit" value="Submit" />
|
||||
</div>
|
||||
<div class="expression_buttons flex-container spaceEvenly">
|
||||
<div id="expression_upload_pack_button" class="menu_button">
|
||||
<i class="fa-solid fa-file-zipper"></i>
|
||||
<span data-i18n="Upload sprite pack (ZIP)">Upload sprite pack (ZIP)</span>
|
||||
</div>
|
||||
<div id="expression_override_cleanup_button" class="menu_button">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
<span data-i18n="Remove all image overrides">Remove all image overrides</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
<b data-i18n="Hint:">Hint:</b>
|
||||
<i>
|
||||
<span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
|
||||
<span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt>
|
||||
</i>
|
||||
</p>
|
||||
<p>
|
||||
<i>
|
||||
<span>In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a
|
||||
dash.
|
||||
Examples: </span><tt>joy.png</tt>, <tt>joy-1.png</tt>, <tt>joy.expressive.png</tt>
|
||||
</i>
|
||||
</p>
|
||||
<h3 id="image_list_header">
|
||||
<strong data-i18n="Sprite set:">Sprite set:</strong> <span id="image_list_header_name"></span>
|
||||
</h3>
|
||||
<div id="image_list"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form>
|
||||
<input type="file" id="expression_upload_pack" name="expression_upload_pack" accept="application/zip" hidden>
|
||||
<input type="file" id="expression_upload" name="expression_upload" accept="image/*" hidden>
|
||||
</form>
|
||||
</div>
|
||||
220
web-app/public/scripts/extensions/expressions/style.css
Normal file
220
web-app/public/scripts/extensions/expressions/style.css
Normal file
@@ -0,0 +1,220 @@
|
||||
.expression-helper {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#expression-wrapper {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--topBarBlockSize));
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
#visual-novel-wrapper {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--topBarBlockSize));
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#visual-novel-wrapper .expression-holder {
|
||||
width: max-content;
|
||||
display: flex;
|
||||
left: unset;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
#visual-novel-wrapper .hidden {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/*#visual-novel-wrapper img.expression {
|
||||
object-fit: cover;
|
||||
}*/
|
||||
|
||||
.expression-holder {
|
||||
min-width: 100px;
|
||||
min-height: 100px;
|
||||
max-height: 90vh;
|
||||
max-width: 90vh;
|
||||
width: calc((100vw - var(--sheldWidth)) /2);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
padding: 0;
|
||||
left: 0;
|
||||
filter: drop-shadow(2px 2px 2px #51515199);
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#no_chat_expressions {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
font-weight: bold;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#image_list_header_name {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
img.expression {
|
||||
min-width: 100px;
|
||||
min-height: 100px;
|
||||
max-height: 90vh;
|
||||
max-width: 90vh;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding: 0;
|
||||
vertical-align: bottom;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
img.expression[src=""] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
img.expression.default {
|
||||
vertical-align: middle;
|
||||
max-height: 120px;
|
||||
object-fit: contain !important;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.expression-clone {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.debug-image {
|
||||
display: none;
|
||||
visibility: collapse;
|
||||
opacity: 0;
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
.expression_list_item {
|
||||
position: relative;
|
||||
max-width: 20%;
|
||||
min-width: 100px;
|
||||
max-height: 200px;
|
||||
background-color: #515151b0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expression_list_image_container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expression_list_title {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
background-color: #000000a8;
|
||||
width: 100%;
|
||||
height: 20%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
line-height: 1;
|
||||
}
|
||||
.expression_list_custom {
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.expression_list_buttons {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
/* height: 20%; */
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.menu_button.expression_list_delete,
|
||||
.menu_button.expression_list_upload {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.expression_list_image {
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#image_list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: 1rem;
|
||||
margin: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
row-gap: 1rem;
|
||||
}
|
||||
|
||||
#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
|
||||
color: green;
|
||||
}
|
||||
|
||||
#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title {
|
||||
color: darkolivegreen;
|
||||
}
|
||||
#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before {
|
||||
content: '➕';
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: -9px;
|
||||
font-size: 14px;
|
||||
color: transparent;
|
||||
text-shadow: 0 0 0 darkolivegreen;
|
||||
}
|
||||
|
||||
#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.expression_settings label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
.expression_settings label input {
|
||||
margin-left: 0px !important;
|
||||
}
|
||||
|
||||
.expression_buttons .menu_button {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"],
|
||||
#expressions_container:has(#expressions_allow_multiple:not(:checked)) label[for="expressions_reroll_if_same"] {
|
||||
opacity: 0.3;
|
||||
transition: opacity var(--animation-duration) ease;
|
||||
}
|
||||
#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:hover,
|
||||
#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:focus {
|
||||
opacity: unset;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="m-b-1" data-i18n="upload_expression_request">Please enter a name for the sprite (without extension).</div>
|
||||
<div class="m-b-1" data-i18n="upload_expression_naming_1">
|
||||
Sprite names must follow the naming schema for the selected expression: {{expression}}
|
||||
</div>
|
||||
<div data-i18n="upload_expression_naming_2">
|
||||
For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'.
|
||||
</div>
|
||||
<span class="m-b-1" data-i18n="Examples:">Examples:</span> <tt>{{expression}}.png</tt>, <tt>{{expression}}-1.png</tt>, <tt>{{expression}}.expressive.png</tt>
|
||||
{{#if clickedFileName}}
|
||||
<div class="m-t-1" data-i18n="upload_expression_replace">Click 'Replace' to replace the existing expression:</div>
|
||||
<tt>{{clickedFileName}}</tt>
|
||||
{{/if}}
|
||||
@@ -0,0 +1,2 @@
|
||||
<!-- I18n data for tools used to auto generate translations -->
|
||||
<div data-i18n="Show Gallery">Show Gallery</div>
|
||||
833
web-app/public/scripts/extensions/gallery/index.js
Normal file
833
web-app/public/scripts/extensions/gallery/index.js
Normal file
@@ -0,0 +1,833 @@
|
||||
import {
|
||||
eventSource,
|
||||
this_chid,
|
||||
characters,
|
||||
getRequestHeaders,
|
||||
event_types,
|
||||
animation_duration,
|
||||
animation_easing,
|
||||
} from '../../../script.js';
|
||||
import { groups, selected_group } from '../../group-chats.js';
|
||||
import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension, getVideoThumbnail, clamp } from '../../utils.js';
|
||||
import { loadMovingUIState } from '../../power-user.js';
|
||||
import { dragElement } from '../../RossAscends-mods.js';
|
||||
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
||||
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
|
||||
import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
|
||||
import { DragAndDropHandler } from '../../dragdrop.js';
|
||||
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { t, translate } from '../../i18n.js';
|
||||
import { Popup } from '../../popup.js';
|
||||
import { deleteMediaFromServer } from '../../chats.js';
|
||||
import { MEDIA_REQUEST_TYPE, VIDEO_EXTENSIONS } from '../../constants.js';
|
||||
|
||||
const isVideo = (/** @type {string} */ url) => VIDEO_EXTENSIONS.some(ext => new RegExp(`.${ext}$`, 'i').test(url));
|
||||
const extensionName = 'gallery';
|
||||
const extensionFolderPath = `scripts/extensions/${extensionName}/`;
|
||||
let firstTime = true;
|
||||
let deleteModeActive = false;
|
||||
|
||||
|
||||
// Remove all draggables associated with the gallery
|
||||
$('#movingDivs').on('click', '.dragClose', function () {
|
||||
const relatedId = $(this).data('related-id');
|
||||
if (!relatedId) return;
|
||||
const relatedElement = $(`#movingDivs > .draggable[id="${relatedId}"]`);
|
||||
relatedElement.transition({
|
||||
opacity: 0,
|
||||
duration: animation_duration,
|
||||
easing: animation_easing,
|
||||
complete: () => {
|
||||
relatedElement.remove();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const CUSTOM_GALLERY_REMOVED_EVENT = 'galleryRemoved';
|
||||
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (node instanceof HTMLElement && node.tagName === 'DIV' && node.id === 'gallery') {
|
||||
eventSource.emit(CUSTOM_GALLERY_REMOVED_EVENT);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: false,
|
||||
});
|
||||
|
||||
const SORT = Object.freeze({
|
||||
NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Name (A-Z)` },
|
||||
NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Name (Z-A)` },
|
||||
DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Newest` },
|
||||
DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Oldest` },
|
||||
});
|
||||
|
||||
const defaultSettings = Object.freeze({
|
||||
folders: {},
|
||||
sort: SORT.DATE_ASC.value,
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes the settings for the gallery extension.
|
||||
*/
|
||||
function initSettings() {
|
||||
let shouldSave = false;
|
||||
const context = SillyTavern.getContext();
|
||||
if (!context.extensionSettings.gallery) {
|
||||
context.extensionSettings.gallery = structuredClone(defaultSettings);
|
||||
shouldSave = true;
|
||||
}
|
||||
for (const key of Object.keys(defaultSettings)) {
|
||||
if (!Object.hasOwn(context.extensionSettings.gallery, key)) {
|
||||
context.extensionSettings.gallery[key] = structuredClone(defaultSettings[key]);
|
||||
shouldSave = true;
|
||||
}
|
||||
}
|
||||
if (shouldSave) {
|
||||
context.saveSettingsDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the gallery folder for a given character.
|
||||
* @param {Character} char Character data
|
||||
* @returns {string} The gallery folder for the character
|
||||
*/
|
||||
function getGalleryFolder(char) {
|
||||
return SillyTavern.getContext().extensionSettings.gallery.folders[char?.avatar] ?? char?.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of gallery items based on a given URL. This function calls an API endpoint
|
||||
* to get the filenames and then constructs the item list.
|
||||
*
|
||||
* @param {string} url - The base URL to retrieve the list of images.
|
||||
* @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error.
|
||||
*/
|
||||
async function getGalleryItems(url) {
|
||||
const sortValue = getSortOrder();
|
||||
const sortObj = Object.values(SORT).find(it => it.value === sortValue) ?? SORT.DATE_ASC;
|
||||
const response = await fetch('/api/images/list', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({
|
||||
folder: url,
|
||||
sortField: sortObj.field,
|
||||
sortOrder: sortObj.order,
|
||||
type: MEDIA_REQUEST_TYPE.IMAGE | MEDIA_REQUEST_TYPE.VIDEO,
|
||||
}),
|
||||
});
|
||||
|
||||
url = await getSanitizedFilename(url);
|
||||
|
||||
const data = await response.json();
|
||||
const items = [];
|
||||
|
||||
for (const file of data) {
|
||||
const item = {
|
||||
src: `user/images/${url}/${file}`,
|
||||
srct: `user/images/${url}/${file}`,
|
||||
title: '', // Optional title for each item
|
||||
};
|
||||
|
||||
if (isVideo(file)) {
|
||||
try {
|
||||
// 150px of max height with some allowance for various aspect ratios
|
||||
const maxSide = Math.round(150 * 1.5);
|
||||
item.srct = await getVideoThumbnail(item.src, maxSide, maxSide);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate video thumbnail for gallery:', error);
|
||||
}
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of gallery folders. This function calls an API endpoint
|
||||
* @returns {Promise<string[]>} - Resolves with an array of gallery folders.
|
||||
*/
|
||||
async function getGalleryFolders() {
|
||||
try {
|
||||
const response = await fetch('/api/images/folders', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders({ omitContentType: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error. Status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch gallery folders:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a gallery item based on the provided URL.
|
||||
* @param {string} url - The URL of the image to be deleted.
|
||||
*/
|
||||
async function deleteGalleryItem(url) {
|
||||
const isDeleted = await deleteMediaFromServer(url, false);
|
||||
if (isDeleted) {
|
||||
toastr.success(t`Image deleted successfully.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the sort order for the gallery.
|
||||
* @param {string} order Sort order
|
||||
*/
|
||||
function setSortOrder(order) {
|
||||
const context = SillyTavern.getContext();
|
||||
context.extensionSettings.gallery.sort = order;
|
||||
context.saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current sort order for the gallery.
|
||||
* @returns {string} The current sort order for the gallery.
|
||||
*/
|
||||
function getSortOrder() {
|
||||
return SillyTavern.getContext().extensionSettings.gallery.sort ?? defaultSettings.sort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a gallery using the provided items and sets up the drag-and-drop functionality.
|
||||
* It uses the nanogallery2 library to display the items and also initializes
|
||||
* event listeners to handle drag-and-drop of files onto the gallery.
|
||||
*
|
||||
* @param {Array<Object>} items - An array of objects representing the items to display in the gallery.
|
||||
* @param {string} url - The URL to use when a file is dropped onto the gallery for uploading.
|
||||
* @returns {Promise<void>} - Promise representing the completion of the gallery initialization.
|
||||
*/
|
||||
async function initGallery(items, url) {
|
||||
// Exposed defaults for future tweaking
|
||||
const thumbnailHeight = 150;
|
||||
const paginationVisiblePages = 5;
|
||||
const paginationMaxLinesPerPage = 2;
|
||||
const galleryMaxRows = clamp(Math.floor((window.innerHeight * 0.9 - 75) / thumbnailHeight), 1, 10);
|
||||
|
||||
const nonce = `nonce-${Math.random().toString(36).substring(2, 15)}`;
|
||||
const gallery = $('#dragGallery');
|
||||
gallery.addClass(nonce);
|
||||
gallery.nanogallery2({
|
||||
'items': items,
|
||||
thumbnailWidth: 'auto',
|
||||
thumbnailHeight: thumbnailHeight,
|
||||
paginationVisiblePages: paginationVisiblePages,
|
||||
paginationMaxLinesPerPage: paginationMaxLinesPerPage,
|
||||
galleryMaxRows: galleryMaxRows,
|
||||
galleryPaginationTopButtons: false,
|
||||
galleryNavigationOverlayButtons: true,
|
||||
galleryPaginationMode: 'rectangles',
|
||||
galleryTheme: {
|
||||
navigationBar: { background: 'none', borderTop: '', borderBottom: '', borderRight: '', borderLeft: '' },
|
||||
navigationBreadcrumb: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' },
|
||||
navigationFilter: { color: '#ddd', background: '#111', colorSelected: '#fff', backgroundSelected: '#111', borderRadius: '4px' },
|
||||
navigationPagination: { background: '#111', color: '#fff', colorHover: '#ccc', borderRadius: '4px' },
|
||||
thumbnail: { background: '#444', backgroundImage: 'linear-gradient(315deg, #111 0%, #445 90%)', borderColor: '#000', borderRadius: '0px', labelOpacity: 1, labelBackground: 'rgba(34, 34, 34, 0)', titleColor: '#fff', titleBgColor: 'transparent', titleShadow: '', descriptionColor: '#ccc', descriptionBgColor: 'transparent', descriptionShadow: '', stackBackground: '#aaa' },
|
||||
thumbnailIcon: { padding: '5px', color: '#fff', shadow: '' },
|
||||
pagination: { background: '#181818', backgroundSelected: '#666', color: '#fff', borderRadius: '2px', shapeBorder: '3px solid var(--SmartThemeQuoteColor)', shapeColor: '#444', shapeSelectedColor: '#aaa' },
|
||||
},
|
||||
galleryDisplayMode: 'pagination',
|
||||
fnThumbnailOpen: viewWithDragbox,
|
||||
fnThumbnailInit: function (/** @type {JQuery<HTMLElement>} */ $thumbnail, /** @type {{src: string}} */ item) {
|
||||
if (!item?.src) return;
|
||||
$thumbnail.attr('title', String(item.src).split('/').pop());
|
||||
},
|
||||
});
|
||||
|
||||
const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files) => {
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload each file
|
||||
for (const file of files) {
|
||||
await uploadFile(file, url);
|
||||
}
|
||||
|
||||
// Refresh the gallery
|
||||
const newItems = await getGalleryItems(url);
|
||||
$('#dragGallery').closest('#gallery').remove();
|
||||
await makeMovable(url);
|
||||
await delay(100);
|
||||
await initGallery(newItems, url);
|
||||
});
|
||||
|
||||
const resizeHandler = function () {
|
||||
gallery.nanogallery2('resize');
|
||||
};
|
||||
|
||||
eventSource.on('resizeUI', resizeHandler);
|
||||
|
||||
eventSource.once(event_types.CHAT_CHANGED, function () {
|
||||
gallery.closest('#gallery').remove();
|
||||
});
|
||||
|
||||
eventSource.once(CUSTOM_GALLERY_REMOVED_EVENT, function () {
|
||||
gallery.nanogallery2('destroy');
|
||||
dragDropHandler.destroy();
|
||||
eventSource.removeListener('resizeUI', resizeHandler);
|
||||
});
|
||||
|
||||
// Set dropzone height to be the same as the parent
|
||||
gallery.css('height', gallery.parent().css('height'));
|
||||
|
||||
//let images populate first
|
||||
await delay(100);
|
||||
//unset the height (which must be getting set by the gallery library at some point)
|
||||
gallery.css('height', 'unset');
|
||||
//force a resize to make images display correctly
|
||||
gallery.nanogallery2('resize');
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a character gallery using the nanogallery2 library.
|
||||
*
|
||||
* This function takes care of:
|
||||
* - Loading necessary resources for the gallery on the first invocation.
|
||||
* - Preparing gallery items based on the character or group selection.
|
||||
* - Handling the drag-and-drop functionality for image upload.
|
||||
* - Displaying the gallery in a popup.
|
||||
* - Cleaning up resources when the gallery popup is closed.
|
||||
*
|
||||
* @returns {Promise<void>} - Promise representing the completion of the gallery display process.
|
||||
*/
|
||||
async function showCharGallery(deleteModeState = false) {
|
||||
// Load necessary files if it's the first time calling the function
|
||||
if (firstTime) {
|
||||
await loadFileToDocument(
|
||||
`${extensionFolderPath}nanogallery2.woff.min.css`,
|
||||
'css',
|
||||
);
|
||||
await loadFileToDocument(
|
||||
`${extensionFolderPath}jquery.nanogallery2.min.js`,
|
||||
'js',
|
||||
);
|
||||
firstTime = false;
|
||||
toastr.info('Images can also be found in the folder `user/images`', 'Drag and drop images onto the gallery to upload them', { timeOut: 6000 });
|
||||
}
|
||||
|
||||
try {
|
||||
deleteModeActive = deleteModeState;
|
||||
let url = selected_group || this_chid;
|
||||
if (!selected_group && this_chid !== undefined) {
|
||||
url = getGalleryFolder(characters[this_chid]);
|
||||
}
|
||||
|
||||
const items = await getGalleryItems(url);
|
||||
// if there already is a gallery, destroy it and place this one in its place
|
||||
$('#dragGallery').closest('#gallery').remove();
|
||||
await makeMovable(url);
|
||||
await delay(100);
|
||||
await initGallery(items, url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a given file to a specified URL.
|
||||
* Once the file is uploaded, it provides a success message using toastr,
|
||||
* destroys the existing gallery, fetches the latest items, and reinitializes the gallery.
|
||||
*
|
||||
* @param {File} file - The file object to be uploaded.
|
||||
* @param {string} url - The URL indicating where the file should be uploaded.
|
||||
* @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh.
|
||||
*/
|
||||
async function uploadFile(file, url) {
|
||||
try {
|
||||
// Convert the file to a base64 string
|
||||
const fileBase64 = await getBase64Async(file);
|
||||
const base64Data = fileBase64.split(',')[1];
|
||||
const extension = getFileExtension(file);
|
||||
const path = await saveBase64AsFile(base64Data, url, '', extension);
|
||||
|
||||
toastr.success(t`File uploaded successfully. Saved at: ${path}`);
|
||||
} catch (error) {
|
||||
console.error('There was an issue uploading the file:', error);
|
||||
|
||||
// Replacing alert with toastr error notification
|
||||
toastr.error(t`Failed to upload the file.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new draggable container based on a template.
|
||||
* This function takes a template with the ID 'generic_draggable_template' and clones it.
|
||||
* The cloned element has its attributes set, a new child div appended, and is made visible on the body.
|
||||
* Additionally, it sets up the element to prevent dragging on its images.
|
||||
* @param {string} url - The URL of the image source.
|
||||
* @returns {Promise<void>} - Promise representing the completion of the draggable container creation.
|
||||
*/
|
||||
async function makeMovable(url) {
|
||||
console.debug('making new container from template');
|
||||
const id = 'gallery';
|
||||
const template = $('#generic_draggable_template').html();
|
||||
const newElement = $(template);
|
||||
newElement.css({ 'background-color': 'var(--SmartThemeBlurTintColor)', 'opacity': 0 });
|
||||
newElement.attr('forChar', id);
|
||||
newElement.attr('id', id);
|
||||
newElement.find('.drag-grabber').attr('id', `${id}header`);
|
||||
const dragTitle = newElement.find('.dragTitle');
|
||||
dragTitle.addClass('flex-container justifySpaceBetween alignItemsBaseline');
|
||||
const titleText = document.createElement('span');
|
||||
titleText.textContent = t`Image Gallery`;
|
||||
dragTitle.append(titleText);
|
||||
|
||||
// Create a container for the controls
|
||||
const controlsContainer = document.createElement('div');
|
||||
controlsContainer.classList.add('flex-container', 'alignItemsCenter');
|
||||
|
||||
const sortSelect = document.createElement('select');
|
||||
sortSelect.classList.add('gallery-sort-select');
|
||||
|
||||
for (const sort of Object.values(SORT)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = sort.value;
|
||||
option.textContent = sort.label;
|
||||
sortSelect.appendChild(option);
|
||||
}
|
||||
|
||||
sortSelect.addEventListener('change', async () => {
|
||||
const selectedOption = sortSelect.options[sortSelect.selectedIndex].value;
|
||||
setSortOrder(selectedOption);
|
||||
closeButton.trigger('click');
|
||||
await showCharGallery();
|
||||
});
|
||||
|
||||
sortSelect.value = getSortOrder();
|
||||
controlsContainer.appendChild(sortSelect);
|
||||
|
||||
// Create the "Add Image" button
|
||||
const addImageButton = document.createElement('div');
|
||||
addImageButton.classList.add('menu_button', 'menu_button_icon', 'interactable');
|
||||
addImageButton.title = 'Add Image';
|
||||
addImageButton.innerHTML = '<i class="fa-solid fa-plus fa-fw"></i><div>Add Image</div>';
|
||||
|
||||
// Create a hidden file input
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*,video/*';
|
||||
fileInput.multiple = true;
|
||||
fileInput.style.display = 'none';
|
||||
|
||||
// Trigger file input when the button is clicked
|
||||
addImageButton.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// Handle file selection
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const files = fileInput.files;
|
||||
if (files.length > 0) {
|
||||
for (const file of files) {
|
||||
await uploadFile(file, url);
|
||||
}
|
||||
// Refresh the gallery
|
||||
closeButton.trigger('click');
|
||||
await showCharGallery();
|
||||
}
|
||||
});
|
||||
|
||||
controlsContainer.appendChild(addImageButton);
|
||||
dragTitle.append(controlsContainer);
|
||||
newElement.append(fileInput); // Append hidden file input to the main element
|
||||
|
||||
// add no-scrollbar class to this element
|
||||
newElement.addClass('no-scrollbar');
|
||||
|
||||
// get the close button and set its id and data-related-id
|
||||
const closeButton = newElement.find('.dragClose');
|
||||
closeButton.attr('id', `${id}close`);
|
||||
closeButton.attr('data-related-id', `${id}`);
|
||||
|
||||
const topBarElement = document.createElement('div');
|
||||
topBarElement.classList.add('flex-container', 'alignItemsCenter');
|
||||
|
||||
const onChangeFolder = async (/** @type {Event} */ e) => {
|
||||
if (e instanceof KeyboardEvent && e.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newUrl = await getSanitizedFilename(galleryFolderInput.value);
|
||||
updateGalleryFolder(newUrl);
|
||||
closeButton.trigger('click');
|
||||
await showCharGallery();
|
||||
toastr.info(t`Gallery folder changed to ${newUrl}`);
|
||||
galleryFolderInput.value = newUrl;
|
||||
} catch (error) {
|
||||
console.error('Failed to change gallery folder:', error);
|
||||
toastr.error(error?.message || t`Unknown error`, t`Failed to change gallery folder`);
|
||||
}
|
||||
};
|
||||
|
||||
const onRestoreFolder = async () => {
|
||||
try {
|
||||
restoreGalleryFolder();
|
||||
closeButton.trigger('click');
|
||||
await showCharGallery();
|
||||
} catch (error) {
|
||||
console.error('Failed to restore gallery folder:', error);
|
||||
toastr.error(error?.message || t`Unknown error`, t`Failed to restore gallery folder`);
|
||||
}
|
||||
};
|
||||
|
||||
const galleryFolderInput = document.createElement('input');
|
||||
galleryFolderInput.type = 'text';
|
||||
galleryFolderInput.placeholder = t`Folder Name`;
|
||||
galleryFolderInput.title = t`Enter a folder name to change the gallery folder`;
|
||||
galleryFolderInput.value = url;
|
||||
galleryFolderInput.classList.add('text_pole', 'gallery-folder-input', 'flex1');
|
||||
galleryFolderInput.addEventListener('keyup', onChangeFolder);
|
||||
|
||||
const galleryFolderAccept = document.createElement('div');
|
||||
galleryFolderAccept.classList.add('right_menu_button', 'fa-solid', 'fa-check', 'fa-fw');
|
||||
galleryFolderAccept.title = t`Change gallery folder`;
|
||||
galleryFolderAccept.addEventListener('click', onChangeFolder);
|
||||
|
||||
const galleryDeleteMode = document.createElement('div');
|
||||
galleryDeleteMode.classList.add('right_menu_button', 'fa-solid', 'fa-trash', 'fa-fw');
|
||||
galleryDeleteMode.classList.toggle('warning', deleteModeActive);
|
||||
galleryDeleteMode.title = t`Delete mode`;
|
||||
galleryDeleteMode.addEventListener('click', () => {
|
||||
deleteModeActive = !deleteModeActive;
|
||||
galleryDeleteMode.classList.toggle('warning', deleteModeActive);
|
||||
if (deleteModeActive) {
|
||||
toastr.info(t`Delete mode is ON. Click on images you want to delete.`);
|
||||
}
|
||||
});
|
||||
|
||||
const galleryFolderRestore = document.createElement('div');
|
||||
galleryFolderRestore.classList.add('right_menu_button', 'fa-solid', 'fa-recycle', 'fa-fw');
|
||||
galleryFolderRestore.title = t`Restore gallery folder`;
|
||||
galleryFolderRestore.addEventListener('click', onRestoreFolder);
|
||||
|
||||
topBarElement.appendChild(galleryFolderInput);
|
||||
topBarElement.appendChild(galleryFolderAccept);
|
||||
topBarElement.appendChild(galleryDeleteMode);
|
||||
topBarElement.appendChild(galleryFolderRestore);
|
||||
newElement.append(topBarElement);
|
||||
|
||||
// Populate the gallery folder input with a list of available folders
|
||||
const folders = await getGalleryFolders();
|
||||
$(galleryFolderInput)
|
||||
.autocomplete({
|
||||
source: (i, o) => {
|
||||
const term = i.term.toLowerCase();
|
||||
const filtered = folders.filter(f => f.toLowerCase().includes(term));
|
||||
o(filtered);
|
||||
},
|
||||
select: (e, u) => {
|
||||
galleryFolderInput.value = u.item.value;
|
||||
onChangeFolder(e);
|
||||
},
|
||||
minLength: 0,
|
||||
})
|
||||
.on('focus', () => $(galleryFolderInput).autocomplete('search', ''));
|
||||
|
||||
//add a div for the gallery
|
||||
newElement.append('<div id="dragGallery"></div>');
|
||||
|
||||
$('#dragGallery').css('display', 'block');
|
||||
|
||||
$('#movingDivs').append(newElement);
|
||||
|
||||
loadMovingUIState();
|
||||
$(`.draggable[forChar="${id}"]`).css('display', 'block');
|
||||
dragElement(newElement);
|
||||
newElement.transition({
|
||||
opacity: 1,
|
||||
duration: animation_duration,
|
||||
easing: animation_easing,
|
||||
});
|
||||
|
||||
$(`.draggable[forChar="${id}"] img`).on('dragstart', (e) => {
|
||||
console.log('saw drag on avatar!');
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the gallery folder to a new URL.
|
||||
* @param {string} newUrl - The new URL to set for the gallery folder.
|
||||
*/
|
||||
function updateGalleryFolder(newUrl) {
|
||||
if (!newUrl) {
|
||||
throw new Error('Folder name cannot be empty');
|
||||
}
|
||||
const context = SillyTavern.getContext();
|
||||
if (context.groupId) {
|
||||
throw new Error('Cannot change gallery folder in group chat');
|
||||
}
|
||||
if (context.characterId === undefined) {
|
||||
throw new Error('Character is not selected');
|
||||
}
|
||||
const avatar = context.characters[context.characterId]?.avatar;
|
||||
const name = context.characters[context.characterId]?.name;
|
||||
if (!avatar) {
|
||||
throw new Error('Character PNG ID is not found');
|
||||
}
|
||||
if (newUrl === name) {
|
||||
// Default folder name is picked, remove the override
|
||||
delete context.extensionSettings.gallery.folders[avatar];
|
||||
} else {
|
||||
// Custom folder name is provided, set the override
|
||||
context.extensionSettings.gallery.folders[avatar] = newUrl;
|
||||
}
|
||||
context.saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the gallery folder to the default value.
|
||||
*/
|
||||
function restoreGalleryFolder() {
|
||||
const context = SillyTavern.getContext();
|
||||
if (context.groupId) {
|
||||
throw new Error('Cannot change gallery folder in group chat');
|
||||
}
|
||||
if (context.characterId === undefined) {
|
||||
throw new Error('Character is not selected');
|
||||
}
|
||||
const avatar = context.characters[context.characterId]?.avatar;
|
||||
if (!avatar) {
|
||||
throw new Error('Character PNG ID is not found');
|
||||
}
|
||||
const existingOverride = context.extensionSettings.gallery.folders[avatar];
|
||||
if (!existingOverride) {
|
||||
throw new Error('No folder override found');
|
||||
}
|
||||
delete context.extensionSettings.gallery.folders[avatar];
|
||||
context.saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new draggable image based on a template.
|
||||
*
|
||||
* This function clones a provided template with the ID 'generic_draggable_template',
|
||||
* appends the given image URL, ensures the element has a unique ID,
|
||||
* and attaches the element to the body. After appending, it also prevents
|
||||
* dragging on the appended image.
|
||||
*
|
||||
* @param {string} id - A base identifier for the new draggable element.
|
||||
* @param {string} url - The URL of the image to be added to the draggable element.
|
||||
*/
|
||||
function makeDragImg(id, url) {
|
||||
// Step 1: Clone the template content
|
||||
const template = document.getElementById('generic_draggable_template');
|
||||
|
||||
if (!(template instanceof HTMLTemplateElement)) {
|
||||
console.error('The element is not a <template> tag');
|
||||
return;
|
||||
}
|
||||
|
||||
const newElement = document.importNode(template.content, true);
|
||||
|
||||
// Step 2: Append the given image
|
||||
const mediaElement = isVideo(url)
|
||||
? document.createElement('video')
|
||||
: document.createElement('img');
|
||||
mediaElement.src = url;
|
||||
if (mediaElement instanceof HTMLVideoElement) {
|
||||
mediaElement.controls = true;
|
||||
mediaElement.autoplay = true;
|
||||
}
|
||||
|
||||
let uniqueId = `draggable_${id}`;
|
||||
const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable'));
|
||||
if (draggableElem) {
|
||||
draggableElem.appendChild(mediaElement);
|
||||
|
||||
// Find a unique id for the draggable element
|
||||
|
||||
let counter = 1;
|
||||
while (document.getElementById(uniqueId)) {
|
||||
uniqueId = `draggable_${id}_${counter}`;
|
||||
counter++;
|
||||
}
|
||||
draggableElem.id = uniqueId;
|
||||
|
||||
// Add the galleryImageDraggable to have unique class
|
||||
draggableElem.classList.add('galleryImageDraggable');
|
||||
|
||||
// Ensure that the newly added element is displayed as block
|
||||
draggableElem.style.display = 'block';
|
||||
//and has no padding unlike other non-zoomed-avatar draggables
|
||||
draggableElem.style.padding = '0';
|
||||
|
||||
// Add an id to the close button
|
||||
// If the close button exists, set related-id
|
||||
const closeButton = /** @type {HTMLElement} */ (draggableElem.querySelector('.dragClose'));
|
||||
if (closeButton) {
|
||||
closeButton.id = `${uniqueId}close`;
|
||||
closeButton.dataset.relatedId = uniqueId;
|
||||
}
|
||||
|
||||
// Find the .drag-grabber and set its matching unique ID
|
||||
const dragGrabber = draggableElem.querySelector('.drag-grabber');
|
||||
if (dragGrabber) {
|
||||
dragGrabber.id = `${uniqueId}header`; // appending _header to make it match the parent's unique ID
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Attach it to the movingDivs container
|
||||
document.getElementById('movingDivs').appendChild(newElement);
|
||||
|
||||
// Step 4: Call dragElement and loadMovingUIState
|
||||
const appendedElement = document.getElementById(uniqueId);
|
||||
if (appendedElement) {
|
||||
var elmntName = $(appendedElement);
|
||||
loadMovingUIState();
|
||||
dragElement(elmntName);
|
||||
|
||||
// Prevent dragging the image
|
||||
$(`#${uniqueId} img`).on('dragstart', (e) => {
|
||||
console.log('saw drag on avatar!');
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
console.error('Failed to append the template content or retrieve the appended content.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a given ID to ensure it can be used as an HTML ID.
|
||||
* This function replaces spaces and non-word characters with dashes.
|
||||
* It also removes any non-ASCII characters.
|
||||
* @param {string} id - The ID to be sanitized.
|
||||
* @returns {string} - The sanitized ID.
|
||||
*/
|
||||
function sanitizeHTMLId(id) {
|
||||
// Replace spaces and non-word characters
|
||||
id = id.replace(/\s+/g, '-')
|
||||
.replace(/[^\x00-\x7F]/g, '-')
|
||||
.replace(/\W/g, '');
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a list of items (containing URLs) and creates a draggable box for the first item.
|
||||
*
|
||||
* If the provided list of items is non-empty, it takes the URL of the first item,
|
||||
* derives an ID from the URL, and uses the makeDragImg function to create
|
||||
* a draggable image element based on that ID and URL.
|
||||
*
|
||||
* @param {Array} items - A list of items where each item has a responsiveURL method that returns a URL.
|
||||
*/
|
||||
function viewWithDragbox(items) {
|
||||
if (items && items.length > 0) {
|
||||
const url = items[0].responsiveURL(); // Get the URL of the clicked image/video
|
||||
if (deleteModeActive) {
|
||||
Popup.show.confirm(t`Are you sure you want to delete this image?`, url)
|
||||
.then(async (confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
deleteGalleryItem(url).then(() => showCharGallery(deleteModeActive));
|
||||
});
|
||||
} else {
|
||||
// ID should just be the last part of the URL, removing the extension
|
||||
const id = sanitizeHTMLId(url.substring(url.lastIndexOf('/') + 1, url.lastIndexOf('.')));
|
||||
makeDragImg(id, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Registers a simple command for opening the char gallery.
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'show-gallery',
|
||||
aliases: ['sg'],
|
||||
callback: () => {
|
||||
showCharGallery();
|
||||
return '';
|
||||
},
|
||||
helpString: 'Shows the gallery.',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'list-gallery',
|
||||
aliases: ['lg'],
|
||||
callback: listGalleryCommand,
|
||||
returns: 'list of images',
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'char',
|
||||
description: 'character name',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
enumProvider: commonEnumProviders.characters('character'),
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'group',
|
||||
description: 'group name',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
enumProvider: commonEnumProviders.characters('group'),
|
||||
}),
|
||||
],
|
||||
helpString: 'List images in the gallery of the current char / group or a specified char / group.',
|
||||
}));
|
||||
|
||||
async function listGalleryCommand(args) {
|
||||
try {
|
||||
let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid);
|
||||
if (!args.char && !args.group && !selected_group && this_chid !== undefined) {
|
||||
url = getGalleryFolder(characters[this_chid]);
|
||||
}
|
||||
|
||||
const items = await getGalleryItems(url);
|
||||
return JSON.stringify(items.map(it => it.src));
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
|
||||
// On extension load, ensure the settings are initialized
|
||||
(function () {
|
||||
initSettings();
|
||||
eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => {
|
||||
const context = SillyTavern.getContext();
|
||||
const galleryFolder = context.extensionSettings.gallery.folders[oldAvatar];
|
||||
if (galleryFolder) {
|
||||
context.extensionSettings.gallery.folders[newAvatar] = galleryFolder;
|
||||
delete context.extensionSettings.gallery.folders[oldAvatar];
|
||||
context.saveSettingsDebounced();
|
||||
}
|
||||
});
|
||||
eventSource.on(event_types.CHARACTER_DELETED, (data) => {
|
||||
const avatar = data?.character?.avatar;
|
||||
if (!avatar) return;
|
||||
const context = SillyTavern.getContext();
|
||||
delete context.extensionSettings.gallery.folders[avatar];
|
||||
context.saveSettingsDebounced();
|
||||
});
|
||||
eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (selectedOptionId) => {
|
||||
if (selectedOptionId === 'show_char_gallery') {
|
||||
showCharGallery();
|
||||
}
|
||||
});
|
||||
|
||||
// Add an option to the dropdown
|
||||
$('#char-management-dropdown').append(
|
||||
$('<option>', {
|
||||
id: 'show_char_gallery',
|
||||
text: translate('Show Gallery'),
|
||||
}),
|
||||
);
|
||||
})();
|
||||
80
web-app/public/scripts/extensions/gallery/jquery.nanogallery2.min.js
vendored
Normal file
80
web-app/public/scripts/extensions/gallery/jquery.nanogallery2.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
web-app/public/scripts/extensions/gallery/manifest.json
Normal file
12
web-app/public/scripts/extensions/gallery/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"display_name": "Gallery",
|
||||
"loading_order": 6,
|
||||
"requires": [],
|
||||
"optional": [
|
||||
],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "City-Unit",
|
||||
"version": "1.5.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
||||
1
web-app/public/scripts/extensions/gallery/nanogallery2.woff.min.css
vendored
Normal file
1
web-app/public/scripts/extensions/gallery/nanogallery2.woff.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
55
web-app/public/scripts/extensions/gallery/style.css
Normal file
55
web-app/public/scripts/extensions/gallery/style.css
Normal file
@@ -0,0 +1,55 @@
|
||||
.nGY2 .nGY2GalleryBottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gallery-folder-input {
|
||||
background-color: transparent;
|
||||
font-size: calc(var(--mainFontSize)* 0.9);
|
||||
opacity: 0.8;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.gallery-folder-input:placeholder-shown {
|
||||
font-style: italic;
|
||||
opacity: 0.5;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.gallery-sort-select {
|
||||
width: max-content;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
overflow-x: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
background-color: var(--black30a);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
background-image: url(/img/down-arrow.svg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px center;
|
||||
background-size: 8px 5px;
|
||||
padding-right: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#gallery .dragTitle {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
#dragGallery {
|
||||
min-height: 25dvh;
|
||||
}
|
||||
|
||||
#gallery .right_menu_button.warning {
|
||||
opacity: 1;
|
||||
filter: unset;
|
||||
}
|
||||
|
||||
.galleryImageDraggable video {
|
||||
width: 100%;
|
||||
}
|
||||
1120
web-app/public/scripts/extensions/memory/index.js
Normal file
1120
web-app/public/scripts/extensions/memory/index.js
Normal file
File diff suppressed because it is too large
Load Diff
13
web-app/public/scripts/extensions/memory/manifest.json
Normal file
13
web-app/public/scripts/extensions/memory/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"display_name": "Summarize",
|
||||
"loading_order": 9,
|
||||
"requires": [],
|
||||
"optional": [
|
||||
"summarize"
|
||||
],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "Cohee#1207",
|
||||
"version": "1.0.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
||||
148
web-app/public/scripts/extensions/memory/settings.html
Normal file
148
web-app/public/scripts/extensions/memory/settings.html
Normal file
@@ -0,0 +1,148 @@
|
||||
<div id="memory_settings">
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<div class="flex-container alignitemscenter margin0">
|
||||
<b data-i18n="ext_sum_title">Summarize</b>
|
||||
<i id="summaryExtensionPopoutButton" class="fa-solid fa-window-restore menu_button margin0"></i>
|
||||
</div>
|
||||
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
|
||||
</div>
|
||||
<div class="inline-drawer-content">
|
||||
<div id="summaryExtensionDrawerContents">
|
||||
<label for="summary_source" data-i18n="ext_sum_with">Summarize with:</label>
|
||||
<select id="summary_source" class="text_pole">
|
||||
<option value="main" data-i18n="ext_sum_main_api">Main API</option>
|
||||
<option value="extras">Extras API (deprecated)</option>
|
||||
<option value="webllm" data-i18n="ext_sum_webllm">WebLLM Extension</option>
|
||||
</select><br>
|
||||
|
||||
<div class="flex-container justifyspacebetween alignitemscenter">
|
||||
<span data-i18n="ext_sum_current_summary">Current summary:</span>
|
||||
<i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="memory_contents" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
|
||||
<span class="flex1"> </span>
|
||||
<div id="memory_restore" class="menu_button margin0" data-i18n="[title]ext_sum_restore_tip" title="Restore a previous summary; use repeatedly to clear summarization state for this chat.">
|
||||
<small data-i18n="ext_sum_restore_previous">Restore Previous</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea id="memory_contents" class="text_pole textarea_compact" rows="6" data-i18n="[placeholder]ext_sum_memory_placeholder" placeholder="Summary will be generated here..."></textarea>
|
||||
<div class="memory_contents_controls">
|
||||
<div id="memory_force_summarize" data-summary-source="main,webllm" class="menu_button menu_button_icon" title="Trigger a summary update right now." data-i18n="[title]ext_sum_force_tip">
|
||||
<i class="fa-solid fa-database"></i>
|
||||
<span data-i18n="ext_sum_force_text">Summarize now</span>
|
||||
</div>
|
||||
<label for="memory_frozen" title="Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API)." data-i18n="[title]Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API)."><input id="memory_frozen" type="checkbox" /><span data-i18n="ext_sum_pause">Pause</span></label>
|
||||
<label data-summary-source="main" for="memory_skipWIAN" title="Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN." data-i18n="[title]Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN.">
|
||||
<input id="memory_skipWIAN" type="checkbox" />
|
||||
<span data-i18n="ext_sum_no_wi_an">No WI/AN</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="memory_contents_controls">
|
||||
<div id="summarySettingsBlockToggle" class="menu_button menu_button_icon" data-i18n="[title]ext_sum_settings_tip" title="Edit summarization prompt, insertion position, etc.">
|
||||
<i class="fa-solid fa-cog"></i>
|
||||
<span data-i18n="ext_sum_settings">Summary Settings</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="summarySettingsBlock" style="display:none;">
|
||||
<div data-summary-source="main">
|
||||
<label data-i18n="ext_sum_prompt_builder">
|
||||
Prompt builder
|
||||
</label>
|
||||
<label class="checkbox_label" for="memory_prompt_builder_raw_blocking" data-i18n="[title]ext_sum_prompt_builder_1_desc" title="Extension will build its own prompt using messages that were not summarized yet. Blocks the chat until the summary is generated.">
|
||||
<input id="memory_prompt_builder_raw_blocking" type="radio" name="memory_prompt_builder" value="1" />
|
||||
<span data-i18n="ext_sum_prompt_builder_1">Raw, blocking</span>
|
||||
</label>
|
||||
<label class="checkbox_label" for="memory_prompt_builder_raw_non_blocking" data-i18n="[title]ext_sum_prompt_builder_2_desc" title="Extension will build its own prompt using messages that were not summarized yet. Does not block the chat while the summary is being generated. Not all backends support this mode.">
|
||||
<input id="memory_prompt_builder_raw_non_blocking" type="radio" name="memory_prompt_builder" value="2" />
|
||||
<span data-i18n="ext_sum_prompt_builder_2">Raw, non-blocking</span>
|
||||
</label>
|
||||
<label class="checkbox_label" id="memory_prompt_builder_default" data-i18n="[title]ext_sum_prompt_builder_3_desc" title="Extension will use the regular main prompt builder and add the summary request to it as the last system message.">
|
||||
<input id="memory_prompt_builder_default" type="radio" name="memory_prompt_builder" value="0" />
|
||||
<span data-i18n="ext_sum_prompt_builder_3">Classic, blocking</span>
|
||||
</label>
|
||||
</div>
|
||||
<div data-summary-source="main,webllm">
|
||||
<label for="memory_prompt" class="title_restorable">
|
||||
<span data-i18n="Summary Prompt">Summary Prompt</span>
|
||||
<div id="memory_prompt_restore" data-i18n="[title]ext_sum_restore_default_prompt_tip" title="Restore default prompt" class="right_menu_button">
|
||||
<div class="fa-solid fa-clock-rotate-left"></div>
|
||||
</div>
|
||||
</label>
|
||||
<textarea id="memory_prompt" class="text_pole textarea_compact" rows="6" data-i18n="[placeholder]ext_sum_prompt_placeholder" placeholder="This prompt will be sent to AI to request the summary generation. {{words}} will resolve to the 'Number of words' parameter."></textarea>
|
||||
<label for="memory_prompt_words"><span data-i18n="ext_sum_target_length_1">Target summary length</span> <span data-i18n="ext_sum_target_length_2">(</span><span id="memory_prompt_words_value"></span><span data-i18n="ext_sum_target_length_3"> words)</span></label>
|
||||
<input id="memory_prompt_words" type="range" value="{{defaultSettings.promptWords}}" min="{{defaultSettings.promptMinWords}}" max="{{defaultSettings.promptMaxWords}}" step="{{defaultSettings.promptWordsStep}}" />
|
||||
<label for="memory_override_response_length">
|
||||
<span data-i18n="ext_sum_api_response_length_1">API response length</span> <span data-i18n="ext_sum_api_response_length_2">(</span><span id="memory_override_response_length_value"></span><span data-i18n="ext_sum_api_response_length_3"> tokens)</span>
|
||||
<small class="memory_disabled_hint" data-i18n="ext_sum_0_default">0 = default</small>
|
||||
</label>
|
||||
<input id="memory_override_response_length" type="range" value="{{defaultSettings.overrideResponseLength}}" min="{{defaultSettings.overrideResponseLengthMin}}" max="{{defaultSettings.overrideResponseLengthMax}}" step="{{defaultSettings.overrideResponseLengthStep}}" />
|
||||
<label for="memory_max_messages_per_request">
|
||||
<span data-i18n="ext_sum_raw_max_msg">[Raw/WebLLM] Max messages per request</span> (<span id="memory_max_messages_per_request_value"></span>)
|
||||
<small class="memory_disabled_hint" data-i18n="ext_sum_0_unlimited">0 = unlimited</small>
|
||||
</label>
|
||||
<input id="memory_max_messages_per_request" type="range" value="{{defaultSettings.maxMessagesPerRequest}}" min="{{defaultSettings.maxMessagesPerRequestMin}}" max="{{defaultSettings.maxMessagesPerRequestMax}}" step="{{defaultSettings.maxMessagesPerRequestStep}}" />
|
||||
<h4 data-i18n="Update frequency" class="textAlignCenter">
|
||||
Update frequency
|
||||
</h4>
|
||||
<label for="memory_prompt_interval" class="title_restorable">
|
||||
<span>
|
||||
<span data-i18n="ext_sum_update_every_messages_1">Update every</span> <span id="memory_prompt_interval_value"></span><span data-i18n="ext_sum_update_every_messages_2"> messages</span>
|
||||
<small class="memory_disabled_hint" data-i18n="ext_sum_0_disable">0 = disable</small>
|
||||
</span>
|
||||
<div id="memory_prompt_interval_auto" data-i18n="[title]ext_sum_auto_adjust_desc" title="Try to automatically adjust the interval based on the chat metrics." class="right_menu_button">
|
||||
<div class="fa-solid fa-wand-magic-sparkles"></div>
|
||||
</div>
|
||||
</label>
|
||||
<input id="memory_prompt_interval" type="range" value="{{defaultSettings.promptInterval}}" min="{{defaultSettings.promptMinInterval}}" max="{{defaultSettings.promptMaxInterval}}" step="{{defaultSettings.promptIntervalStep}}" />
|
||||
<label for="memory_prompt_words_force" class="title_restorable">
|
||||
<span>
|
||||
<span data-i18n="ext_sum_update_every_words_1">Update every</span> <span id="memory_prompt_words_force_value"></span><span data-i18n="ext_sum_update_every_words_2"> words</span>
|
||||
<small class="memory_disabled_hint" data-i18n="ext_sum_0_disable">0 = disable</small>
|
||||
</span>
|
||||
<div id="memory_prompt_words_auto" data-i18n="[title]ext_sum_auto_adjust_desc" title="Try to automatically adjust the interval based on the chat metrics." class="right_menu_button">
|
||||
<div class="fa-solid fa-wand-magic-sparkles"></div>
|
||||
</div>
|
||||
</label>
|
||||
<input id="memory_prompt_words_force" type="range" value="{{defaultSettings.promptForceWords}}" min="{{defaultSettings.promptMinForceWords}}" max="{{defaultSettings.promptMaxForceWords}}" step="{{defaultSettings.promptForceWordsStep}}" />
|
||||
<small data-i18n="ext_sum_both_sliders">If both sliders are non-zero, then both will trigger summary updates at their respective intervals.</small>
|
||||
<hr>
|
||||
</div>
|
||||
<div class="memory_template">
|
||||
<label for="memory_template" data-i18n="ext_sum_injection_template">Injection Template</label>
|
||||
<textarea id="memory_template" class="text_pole textarea_compact" rows="2" data-i18n="[placeholder]ext_sum_memory_template_placeholder" placeholder="{{summary}} will resolve to the current summary contents."></textarea>
|
||||
</div>
|
||||
<label for="memory_position" data-i18n="ext_sum_injection_position">Injection Position</label>
|
||||
<label class="checkbox_label" for="memory_include_wi_scan" data-i18n="[title]ext_sum_include_wi_scan_desc" title="Include the latest summary in the WI scan.">
|
||||
<input id="memory_include_wi_scan" type="checkbox" />
|
||||
<span data-i18n="ext_sum_include_wi_scan">Include in World Info Scanning</span>
|
||||
</label>
|
||||
<div class="radio_group">
|
||||
<label>
|
||||
<input type="radio" name="memory_position" value="-1" />
|
||||
<span data-i18n="None (not injected)">None (not injected)</span>
|
||||
<i class="fa-solid fa-info-circle" title="The summary will not be injected into the prompt. You can still access it via the {{summary}} macro." data-i18n="[title]ext_sum_injection_position_none"></i>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="memory_position" value="2" />
|
||||
<span data-i18n="Before Main Prompt / Story String">Before Main Prompt / Story String</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="memory_position" value="0" />
|
||||
<span data-i18n="After Main Prompt / Story String">After Main Prompt / Story String</span>
|
||||
</label>
|
||||
<label class="flex-container alignItemsCenter" title="How many messages before the current end of the chat." data-i18n="[title]How many messages before the current end of the chat.">
|
||||
<input type="radio" name="memory_position" value="1" />
|
||||
<span data-i18n="In-chat @ Depth">In-chat @ Depth</span> <input id="memory_depth" class="text_pole widthUnset" type="number" min="0" max="9999" />
|
||||
<span data-i18n="as">as</span>
|
||||
<select id="memory_role" class="text_pole widthNatural">
|
||||
<option value="0" data-i18n="System">System</option>
|
||||
<option value="1" data-i18n="User">User</option>
|
||||
<option value="2" data-i18n="Assistant">Assistant</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
47
web-app/public/scripts/extensions/memory/style.css
Normal file
47
web-app/public/scripts/extensions/memory/style.css
Normal file
@@ -0,0 +1,47 @@
|
||||
#memory_settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#memory_contents {
|
||||
field-sizing: content;
|
||||
max-height: 50dvh;
|
||||
}
|
||||
|
||||
#memory_restore {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
label[for="memory_frozen"],
|
||||
label[for="memory_skipWIAN"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
label[for="memory_frozen"] input {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.memory_contents_controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.memory_disabled_hint {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
#summarySettingsBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 5px;
|
||||
}
|
||||
|
||||
#summaryExtensionPopout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 25px;
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import { QuickReply } from '../src/QuickReply.js';
|
||||
import { QuickReplyContextLink } from '../src/QuickReplyContextLink.js';
|
||||
import { QuickReplySet } from '../src/QuickReplySet.js';
|
||||
import { QuickReplySettings } from '../src/QuickReplySettings.js';
|
||||
import { SettingsUi } from '../src/ui/SettingsUi.js';
|
||||
import { onlyUnique } from '../../../utils.js';
|
||||
|
||||
export class QuickReplyApi {
|
||||
/** @type {QuickReplySettings} */ settings;
|
||||
/** @type {SettingsUi} */ settingsUi;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/** @type {QuickReplySettings} */settings, /** @type {SettingsUi} */settingsUi) {
|
||||
this.settings = settings;
|
||||
this.settingsUi = settingsUi;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {QuickReply} qr
|
||||
* @returns {QuickReplySet}
|
||||
*/
|
||||
getSetByQr(qr) {
|
||||
return QuickReplySet.list.find(it=>it.qrList.includes(qr));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns an existing Quick Reply Set by its name.
|
||||
*
|
||||
* @param {string} name name of the quick reply set
|
||||
* @returns the quick reply set, or undefined if not found
|
||||
*/
|
||||
getSetByName(name) {
|
||||
return QuickReplySet.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns an existing Quick Reply by its set's name and its label.
|
||||
*
|
||||
* @param {string} setName name of the quick reply set
|
||||
* @param {string|number} label label or numeric ID of the quick reply
|
||||
* @returns the quick reply, or undefined if not found
|
||||
*/
|
||||
getQrByLabel(setName, label) {
|
||||
const set = this.getSetByName(setName);
|
||||
if (!set) return;
|
||||
if (Number.isInteger(label)) return set.qrList.find(it=>it.id == label);
|
||||
return set.qrList.find(it=>it.label == label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Executes a quick reply by its index and returns the result.
|
||||
*
|
||||
* @param {Number} idx the index (zero-based) of the quick reply to execute
|
||||
* @returns the return value of the quick reply, or undefined if not found
|
||||
*/
|
||||
async executeQuickReplyByIndex(idx) {
|
||||
const qr = [...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? [])]
|
||||
.map(it=>it.set.qrList)
|
||||
.flat()[idx]
|
||||
;
|
||||
if (qr) {
|
||||
return await qr.onExecute();
|
||||
} else {
|
||||
throw new Error(`No quick reply at index "${idx}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an existing quick reply.
|
||||
*
|
||||
* @param {string} setName name of the existing quick reply set
|
||||
* @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID
|
||||
* @param {object} [args] optional arguments
|
||||
* @param {import('../../../slash-commands.js').ExecuteSlashCommandsOptions} [options] optional execution options
|
||||
*/
|
||||
async executeQuickReply(setName, label, args = {}, options = {}) {
|
||||
const qr = this.getQrByLabel(setName, label);
|
||||
if (!qr) {
|
||||
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
|
||||
}
|
||||
return await qr.execute(args, false, false, options);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds or removes a quick reply set to the list of globally active quick reply sets.
|
||||
*
|
||||
* @param {string} name the name of the set
|
||||
* @param {boolean} isVisible whether to show the set's buttons or not
|
||||
*/
|
||||
toggleGlobalSet(name, isVisible = true) {
|
||||
const set = this.getSetByName(name);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${name}" found.`);
|
||||
}
|
||||
if (this.settings.config.hasSet(set)) {
|
||||
this.settings.config.removeSet(set);
|
||||
} else {
|
||||
this.settings.config.addSet(set, isVisible);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a quick reply set to the list of globally active quick reply sets.
|
||||
*
|
||||
* @param {string} name the name of the set
|
||||
* @param {boolean} isVisible whether to show the set's buttons or not
|
||||
*/
|
||||
addGlobalSet(name, isVisible = true) {
|
||||
const set = this.getSetByName(name);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${name}" found.`);
|
||||
}
|
||||
this.settings.config.addSet(set, isVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a quick reply set from the list of globally active quick reply sets.
|
||||
*
|
||||
* @param {string} name the name of the set
|
||||
*/
|
||||
removeGlobalSet(name) {
|
||||
const set = this.getSetByName(name);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${name}" found.`);
|
||||
}
|
||||
this.settings.config.removeSet(set);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds or removes a quick reply set to the list of the current chat's active quick reply sets.
|
||||
*
|
||||
* @param {string} name the name of the set
|
||||
* @param {boolean} isVisible whether to show the set's buttons or not
|
||||
*/
|
||||
toggleChatSet(name, isVisible = true) {
|
||||
if (!this.settings.chatConfig) return;
|
||||
const set = this.getSetByName(name);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${name}" found.`);
|
||||
}
|
||||
if (this.settings.chatConfig.hasSet(set)) {
|
||||
this.settings.chatConfig.removeSet(set);
|
||||
} else {
|
||||
this.settings.chatConfig.addSet(set, isVisible);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a quick reply set to the list of the current chat's active quick reply sets.
|
||||
*
|
||||
* @param {string} name the name of the set
|
||||
* @param {boolean} isVisible whether to show the set's buttons or not
|
||||
*/
|
||||
addChatSet(name, isVisible = true) {
|
||||
if (!this.settings.chatConfig) return;
|
||||
const set = this.getSetByName(name);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${name}" found.`);
|
||||
}
|
||||
this.settings.chatConfig.addSet(set, isVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a quick reply set from the list of the current chat's active quick reply sets.
|
||||
*
|
||||
* @param {string} name the name of the set
|
||||
*/
|
||||
removeChatSet(name) {
|
||||
if (!this.settings.chatConfig) return;
|
||||
const set = this.getSetByName(name);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${name}" found.`);
|
||||
}
|
||||
this.settings.chatConfig.removeSet(set);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a new quick reply in an existing quick reply set.
|
||||
*
|
||||
* @param {string} setName name of the quick reply set to insert the new quick reply into
|
||||
* @param {string} label label for the new quick reply (text on the button)
|
||||
* @param {object} [props]
|
||||
* @param {string} [props.icon] the icon to show on the QR button
|
||||
* @param {boolean} [props.showLabel] whether to show the label even when an icon is assigned
|
||||
* @param {string} [props.message] the message to be sent or slash command to be executed by the new quick reply
|
||||
* @param {string} [props.title] the title / tooltip to be shown on the quick reply button
|
||||
* @param {boolean} [props.isHidden] whether to hide or show the button
|
||||
* @param {boolean} [props.executeOnStartup] whether to execute the quick reply when SillyTavern starts
|
||||
* @param {boolean} [props.executeOnUser] whether to execute the quick reply after a user has sent a message
|
||||
* @param {boolean} [props.executeOnAi] whether to execute the quick reply after the AI has sent a message
|
||||
* @param {boolean} [props.executeOnChatChange] whether to execute the quick reply when a new chat is loaded
|
||||
* @param {boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected
|
||||
* @param {boolean} [props.executeOnNewChat] whether to execute the quick reply when a new chat is created
|
||||
* @param {boolean} [props.executeBeforeGeneration] whether to execute the quick reply before message generation
|
||||
* @param {string} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated
|
||||
* @returns {QuickReply} the new quick reply
|
||||
*/
|
||||
createQuickReply(setName, label, {
|
||||
icon,
|
||||
showLabel,
|
||||
message,
|
||||
title,
|
||||
isHidden,
|
||||
executeOnStartup,
|
||||
executeOnUser,
|
||||
executeOnAi,
|
||||
executeOnChatChange,
|
||||
executeOnGroupMemberDraft,
|
||||
executeOnNewChat,
|
||||
executeBeforeGeneration,
|
||||
automationId,
|
||||
} = {}) {
|
||||
const set = this.getSetByName(setName);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with named "${setName}" found.`);
|
||||
}
|
||||
const qr = set.addQuickReply();
|
||||
qr.label = label ?? '';
|
||||
qr.icon = icon ?? '';
|
||||
qr.showLabel = showLabel ?? false;
|
||||
qr.message = message ?? '';
|
||||
qr.title = title ?? '';
|
||||
qr.isHidden = isHidden ?? false;
|
||||
qr.executeOnStartup = executeOnStartup ?? false;
|
||||
qr.executeOnUser = executeOnUser ?? false;
|
||||
qr.executeOnAi = executeOnAi ?? false;
|
||||
qr.executeOnChatChange = executeOnChatChange ?? false;
|
||||
qr.executeOnGroupMemberDraft = executeOnGroupMemberDraft ?? false;
|
||||
qr.executeOnNewChat = executeOnNewChat ?? false;
|
||||
qr.executeBeforeGeneration = executeBeforeGeneration ?? false;
|
||||
qr.automationId = automationId ?? '';
|
||||
qr.onUpdate();
|
||||
return qr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing quick reply.
|
||||
*
|
||||
* @param {string} setName name of the existing quick reply set
|
||||
* @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID
|
||||
* @param {object} [props]
|
||||
* @param {string} [props.icon] the icon to show on the QR button
|
||||
* @param {boolean} [props.showLabel] whether to show the label even when an icon is assigned
|
||||
* @param {string} [props.newLabel] new label for quick reply (text on the button)
|
||||
* @param {string} [props.message] the message to be sent or slash command to be executed by the quick reply
|
||||
* @param {string} [props.title] the title / tooltip to be shown on the quick reply button
|
||||
* @param {boolean} [props.isHidden] whether to hide or show the button
|
||||
* @param {boolean} [props.executeOnStartup] whether to execute the quick reply when SillyTavern starts
|
||||
* @param {boolean} [props.executeOnUser] whether to execute the quick reply after a user has sent a message
|
||||
* @param {boolean} [props.executeOnAi] whether to execute the quick reply after the AI has sent a message
|
||||
* @param {boolean} [props.executeOnChatChange] whether to execute the quick reply when a new chat is loaded
|
||||
* @param {boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected
|
||||
* @param {boolean} [props.executeOnNewChat] whether to execute the quick reply when a new chat is created
|
||||
* @param {boolean} [props.executeBeforeGeneration] whether to execute the quick reply before message generation
|
||||
* @param {string} [props.automationId] when not empty, the quick reply will be executed when the WI with the given automation ID is activated
|
||||
* @returns {QuickReply} the altered quick reply
|
||||
*/
|
||||
updateQuickReply(setName, label, {
|
||||
icon,
|
||||
showLabel,
|
||||
newLabel,
|
||||
message,
|
||||
title,
|
||||
isHidden,
|
||||
executeOnStartup,
|
||||
executeOnUser,
|
||||
executeOnAi,
|
||||
executeOnChatChange,
|
||||
executeOnGroupMemberDraft,
|
||||
executeOnNewChat,
|
||||
executeBeforeGeneration,
|
||||
automationId,
|
||||
} = {}) {
|
||||
const qr = this.getQrByLabel(setName, label);
|
||||
if (!qr) {
|
||||
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
|
||||
}
|
||||
qr.updateIcon(icon ?? qr.icon);
|
||||
qr.updateShowLabel(showLabel ?? qr.showLabel);
|
||||
qr.updateLabel(newLabel ?? qr.label);
|
||||
qr.updateMessage(message ?? qr.message);
|
||||
qr.updateTitle(title ?? qr.title);
|
||||
qr.isHidden = isHidden ?? qr.isHidden;
|
||||
qr.executeOnStartup = executeOnStartup ?? qr.executeOnStartup;
|
||||
qr.executeOnUser = executeOnUser ?? qr.executeOnUser;
|
||||
qr.executeOnAi = executeOnAi ?? qr.executeOnAi;
|
||||
qr.executeOnChatChange = executeOnChatChange ?? qr.executeOnChatChange;
|
||||
qr.executeOnGroupMemberDraft = executeOnGroupMemberDraft ?? qr.executeOnGroupMemberDraft;
|
||||
qr.executeOnNewChat = executeOnNewChat ?? qr.executeOnNewChat;
|
||||
qr.executeBeforeGeneration = executeBeforeGeneration ?? qr.executeBeforeGeneration;
|
||||
qr.automationId = automationId ?? qr.automationId;
|
||||
qr.onUpdate();
|
||||
return qr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an existing quick reply.
|
||||
*
|
||||
* @param {string} setName name of the existing quick reply set
|
||||
* @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID
|
||||
*/
|
||||
deleteQuickReply(setName, label) {
|
||||
const qr = this.getQrByLabel(setName, label);
|
||||
if (!qr) {
|
||||
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
|
||||
}
|
||||
qr.delete();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds an existing quick reply set as a context menu to an existing quick reply.
|
||||
*
|
||||
* @param {string} setName name of the existing quick reply set containing the quick reply
|
||||
* @param {string|number} label label of the existing quick reply or its numeric ID
|
||||
* @param {string} contextSetName name of the existing quick reply set to be used as a context menu
|
||||
* @param {boolean} isChained whether or not to chain the context menu quick replies
|
||||
*/
|
||||
createContextItem(setName, label, contextSetName, isChained = false) {
|
||||
const qr = this.getQrByLabel(setName, label);
|
||||
const set = this.getSetByName(contextSetName);
|
||||
if (!qr) {
|
||||
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
|
||||
}
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${contextSetName}" found.`);
|
||||
}
|
||||
const cl = new QuickReplyContextLink();
|
||||
cl.set = set;
|
||||
cl.isChained = isChained;
|
||||
qr.addContextLink(cl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a quick reply set from a quick reply's context menu.
|
||||
*
|
||||
* @param {string} setName name of the existing quick reply set containing the quick reply
|
||||
* @param {string|number} label label of the existing quick reply or its numeric ID
|
||||
* @param {string} contextSetName name of the existing quick reply set to be used as a context menu
|
||||
*/
|
||||
deleteContextItem(setName, label, contextSetName) {
|
||||
const qr = this.getQrByLabel(setName, label);
|
||||
const set = this.getSetByName(contextSetName);
|
||||
if (!qr) {
|
||||
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
|
||||
}
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${contextSetName}" found.`);
|
||||
}
|
||||
qr.removeContextLink(set.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all entries from a quick reply's context menu.
|
||||
*
|
||||
* @param {string} setName name of the existing quick reply set containing the quick reply
|
||||
* @param {string|number} label label of the existing quick reply or its numeric ID
|
||||
*/
|
||||
clearContextMenu(setName, label) {
|
||||
const qr = this.getQrByLabel(setName, label);
|
||||
if (!qr) {
|
||||
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
|
||||
}
|
||||
qr.clearContextLinks();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new quick reply set.
|
||||
*
|
||||
* @param {string} name name of the new quick reply set
|
||||
* @param {object} [props]
|
||||
* @param {boolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
|
||||
* @param {boolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
|
||||
* @param {boolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
|
||||
* @returns {Promise<QuickReplySet>} the new quick reply set
|
||||
*/
|
||||
async createSet(name, {
|
||||
disableSend,
|
||||
placeBeforeInput,
|
||||
injectInput,
|
||||
} = {}) {
|
||||
const set = new QuickReplySet();
|
||||
set.name = name;
|
||||
set.disableSend = disableSend ?? false;
|
||||
set.placeBeforeInput = placeBeforeInput ?? false;
|
||||
set.injectInput = injectInput ?? false;
|
||||
const oldSet = this.getSetByName(name);
|
||||
if (oldSet) {
|
||||
QuickReplySet.list.splice(QuickReplySet.list.indexOf(oldSet), 1, set);
|
||||
} else {
|
||||
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(name) == 1);
|
||||
if (idx > -1) {
|
||||
QuickReplySet.list.splice(idx, 0, set);
|
||||
} else {
|
||||
QuickReplySet.list.push(set);
|
||||
}
|
||||
}
|
||||
await set.save();
|
||||
this.settingsUi.rerender();
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing quick reply set.
|
||||
*
|
||||
* @param {string} name name of the existing quick reply set
|
||||
* @param {object} [props]
|
||||
* @param {boolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
|
||||
* @param {boolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
|
||||
* @param {boolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
|
||||
* @returns {Promise<QuickReplySet>} the altered quick reply set
|
||||
*/
|
||||
async updateSet(name, {
|
||||
disableSend,
|
||||
placeBeforeInput,
|
||||
injectInput,
|
||||
} = {}) {
|
||||
const set = this.getSetByName(name);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${name}" found.`);
|
||||
}
|
||||
set.disableSend = disableSend ?? false;
|
||||
set.placeBeforeInput = placeBeforeInput ?? false;
|
||||
set.injectInput = injectInput ?? false;
|
||||
await set.save();
|
||||
this.settingsUi.rerender();
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing quick reply set.
|
||||
*
|
||||
* @param {string} name name of the existing quick reply set
|
||||
*/
|
||||
async deleteSet(name) {
|
||||
const set = this.getSetByName(name);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${name}" found.`);
|
||||
}
|
||||
await set.delete();
|
||||
this.settingsUi.rerender();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets a list of all quick reply sets.
|
||||
*
|
||||
* @returns array with the names of all quick reply sets
|
||||
*/
|
||||
listSets() {
|
||||
return QuickReplySet.list.map(it=>it.name);
|
||||
}
|
||||
/**
|
||||
* Gets a list of all globally active quick reply sets.
|
||||
*
|
||||
* @returns array with the names of all quick reply sets
|
||||
*/
|
||||
listGlobalSets() {
|
||||
return this.settings.config.setList.map(it=>it.set.name);
|
||||
}
|
||||
/**
|
||||
* Gets a list of all quick reply sets activated by the current chat.
|
||||
*
|
||||
* @returns array with the names of all quick reply sets
|
||||
*/
|
||||
listChatSets() {
|
||||
return this.settings.chatConfig?.setList?.flatMap(it=>it.set.name) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all quick replies in the quick reply set.
|
||||
*
|
||||
* @param {string} setName name of the existing quick reply set
|
||||
* @returns array with the labels of this set's quick replies
|
||||
*/
|
||||
listQuickReplies(setName) {
|
||||
const set = this.getSetByName(setName);
|
||||
if (!set) {
|
||||
throw new Error(`No quick reply set with name "${name}" found.`);
|
||||
}
|
||||
return set.qrList.map(it=>it.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all Automation IDs used by quick replies.
|
||||
*
|
||||
* @returns {String[]} array with all automation IDs used by quick replies
|
||||
*/
|
||||
listAutomationIds() {
|
||||
return this
|
||||
.listSets()
|
||||
.flatMap(it => ({ set: it, qrs: this.listQuickReplies(it) }))
|
||||
.map(it => it.qrs?.map(qr => this.getQrByLabel(it.set, qr)?.automationId))
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.filter(onlyUnique)
|
||||
.map(String);
|
||||
}
|
||||
}
|
||||
161
web-app/public/scripts/extensions/quick-reply/html/qrEditor.html
Normal file
161
web-app/public/scripts/extensions/quick-reply/html/qrEditor.html
Normal file
@@ -0,0 +1,161 @@
|
||||
<div id="qr--modalEditor">
|
||||
<div id="qr--main">
|
||||
<h3 data-i18n="Labels and Message">Labels and Message</h3>
|
||||
<div class="qr--labels">
|
||||
<label class="qr--fit">
|
||||
<span class="qr--labelText" data-i18n="Label">Icon</span>
|
||||
<small class="qr--labelHint"> </small>
|
||||
<div class="menu_button fa-fw" id="qr--modal-icon" title="Click to change icon"></div>
|
||||
</label>
|
||||
<div class="label">
|
||||
<span class="qr--labelText" data-i18n="Label">Label</span>
|
||||
<small class="qr--labelHint" data-i18n="(label of the button, if no icon is chosen) ">(label of the button, if no icon is chosen)</small>
|
||||
<div class="qr--inputGroup">
|
||||
<label class="checkbox_label" title="Show label even if an icon is assigned">
|
||||
<input type="checkbox" id="qr--modal-showLabel">
|
||||
Show
|
||||
</label>
|
||||
<input type="text" class="text_pole" id="qr--modal-label">
|
||||
<div class="menu_button fa-fw fa-solid fa-chevron-down" id="qr--modal-switcher" title="Switch to another QR"></div>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
<span class="qr--labelText" data-i18n="Title">Title</span>
|
||||
<small class="qr--labelHint" data-i18n="(tooltip, leave empty to show message or /command)">(tooltip, leave empty to show message or /command)</small>
|
||||
<input type="text" class="text_pole" id="qr--modal-title">
|
||||
</label>
|
||||
</div>
|
||||
<div class="qr--modal-messageContainer">
|
||||
<label for="qr--modal-message" data-i18n="Message / Command:">
|
||||
Message / Command:
|
||||
</label>
|
||||
<div class="qr--modal-editorSettings">
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--modal-wrap">
|
||||
<span data-i18n="Word wrap">Word wrap</span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<span data-i18n="Tab size:">Tab size:</span>
|
||||
<input type="number" min="1" max="9" id="qr--modal-tabSize" class="text_pole">
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--modal-executeShortcut">
|
||||
<span data-i18n="Ctrl+Enter to execute">Ctrl+Enter to execute</span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--modal-syntax">
|
||||
<span>Syntax highlight</span>
|
||||
</label>
|
||||
<small>Ctrl+Alt+Click (or F9) to set / remove breakpoints</small>
|
||||
<small>Ctrl+<span id="qr--modal-commentKey"></span> to toggle block comments</small>
|
||||
</div>
|
||||
<div id="qr--modal-messageHolder">
|
||||
<pre id="qr--modal-messageSyntax"><code id="qr--modal-messageSyntaxInner" class="hljs language-stscript"></code></pre>
|
||||
<textarea id="qr--modal-message" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="qr--resizeHandle"></div>
|
||||
|
||||
|
||||
|
||||
<div id="qr--qrOptions">
|
||||
<h3 data-i18n="Context Menu">Context Menu</h3>
|
||||
<div id="qr--ctxEditor">
|
||||
<template id="qr--ctxItem">
|
||||
<div class="qr--ctxItem" data-order="0">
|
||||
<div class="drag-handle ui-sortable-handle">☰</div>
|
||||
<select class="qr--set"></select>
|
||||
<label class="qr--isChainedLabel checkbox_label" title="When enabled, the current Quick Reply will be sent together with (before) the clicked QR from the context menu.">
|
||||
<span data-i18n="Chaining:">Chaining:</span>
|
||||
<input type="checkbox" class="qr--isChained">
|
||||
</label>
|
||||
<div class="qr--delete menu_button menu_button_icon fa-solid fa-trash-can" title="Remove entry"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="qr--ctxEditorActions">
|
||||
<span id="qr--ctxAdd" class="menu_button menu_button_icon fa-solid fa-plus" title="Add quick reply set to context menu"></span>
|
||||
</div>
|
||||
|
||||
|
||||
<h3 data-i18n="Auto-Execute">Auto-Execute</h3>
|
||||
<div id="qr--autoExec" class="flex-container flexFlowColumn">
|
||||
<label class="checkbox_label" title="Prevent this quick reply from triggering other auto-executed quick replies while auto-executing (i.e., prevent recursive auto-execution)">
|
||||
<input type="checkbox" id="qr--preventAutoExecute" >
|
||||
<span><i class="fa-solid fa-fw fa-plane-slash"></i><span data-i18n="Don't trigger auto-execute">Don't trigger auto-execute</span></span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--isHidden" >
|
||||
<span><i class="fa-solid fa-fw fa-eye-slash"></i><span data-i18n="Invisible (auto-execute only)">Invisible (auto-execute only)</span></span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnStartup" >
|
||||
<span><i class="fa-solid fa-fw fa-rocket"></i><span data-i18n="Execute on startup">Execute on startup</span></span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnUser" >
|
||||
<span><i class="fa-solid fa-fw fa-user"></i><span data-i18n="Execute on user message">Execute on user message</span></span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnAi" >
|
||||
<span><i class="fa-solid fa-fw fa-robot"></i><span data-i18n="Execute on AI message">Execute on AI message</span></span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnChatChange" >
|
||||
<span><i class="fa-solid fa-fw fa-message"></i><span data-i18n="Execute on chat change">Execute on chat change</span></span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnNewChat">
|
||||
<span><i class="fa-solid fa-fw fa-comments"></i><span data-i18n="Execute on new chat">Execute on new chat</span></span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeOnGroupMemberDraft">
|
||||
<span><i class="fa-solid fa-fw fa-people-group"></i><span data-i18n="Execute on group member draft">Execute on group member draft</span></span>
|
||||
</label>
|
||||
<label class="checkbox_label">
|
||||
<input type="checkbox" id="qr--executeBeforeGeneration" >
|
||||
<span><i class="fa-solid fa-fw fa-paper-plane"></i><span data-i18n="Execute before message generation">Execute before message generation</span></span>
|
||||
</label>
|
||||
<div class="flex-container alignItemsBaseline flexFlowColumn flexNoGap" title="Activate this quick reply when a World Info entry with the same Automation ID is triggered.">
|
||||
<small data-i18n="Automation ID:">Automation ID</small>
|
||||
<input type="text" id="qr--automationId" class="text_pole flex1" placeholder="( None )">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h3 data-i18n="Testing">Testing</h3>
|
||||
<div id="qr--modal-executeButtons">
|
||||
<div id="qr--modal-execute" class="qr--modal-executeButton menu_button" title="Execute the quick reply now">
|
||||
<i class="fa-solid fa-play"></i>
|
||||
<span data-i18n="Execute">Execute</span>
|
||||
</div>
|
||||
<div id="qr--modal-pause" class="qr--modal-executeButton menu_button" title="Pause / continue execution">
|
||||
<span class="qr--modal-executeComboIcon">
|
||||
<i class="fa-solid fa-play"></i>
|
||||
<i class="fa-solid fa-pause"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div id="qr--modal-stop" class="qr--modal-executeButton menu_button" title="Abort execution">
|
||||
<i class="fa-solid fa-stop"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qr--modal-executeProgress"></div>
|
||||
<div id="qr--modal-executeErrors"></div>
|
||||
<div id="qr--modal-executeResult"></div>
|
||||
|
||||
<div id="qr--modal-debugButtons">
|
||||
<div title="Resume" id="qr--modal-resume" class="qr--modal-debugButton menu_button"></div>
|
||||
<div title="Step Over" id="qr--modal-step" class="qr--modal-debugButton menu_button"></div>
|
||||
<div title="Step Into" id="qr--modal-stepInto" class="qr--modal-debugButton menu_button"></div>
|
||||
<div title="Step Out" id="qr--modal-stepOut" class="qr--modal-debugButton menu_button"></div>
|
||||
<div title="Minimize" id="qr--modal-minimize" class="qr--modal-debugButton menu_button fa-solid fa-minimize"></div>
|
||||
<div title="Maximize" id="qr--modal-maximize" class="qr--modal-debugButton menu_button fa-solid fa-maximize"></div>
|
||||
</div>
|
||||
<textarea rows="1" id="qr--modal-send_textarea" placeholder="Chat input for use with {{input}}" title="Chat input for use with {{input}}"></textarea>
|
||||
<div id="qr--modal-debugState"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
<div id="qr--settings">
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<strong data-i18n="Quick Reply">Quick Reply</strong>
|
||||
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
|
||||
</div>
|
||||
<div class="inline-drawer-content">
|
||||
<label class="flex-container">
|
||||
<input type="checkbox" id="qr--isEnabled"><span data-i18n="Enable Quick Replies">Enable Quick Replies</span>
|
||||
</label>
|
||||
<label class="flex-container">
|
||||
<input type="checkbox" id="qr--isCombined"><span data-i18n="Combine Quick Replies">Combine Quick Replies</span>
|
||||
</label>
|
||||
<label class="flex-container">
|
||||
<input type="checkbox" id="qr--showPopoutButton"><span data-i18n="Show Popout Button">Show Popout Button (on Desktop)</span>
|
||||
</label>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="qr--global">
|
||||
<div class="qr--head">
|
||||
<div class="qr--title" data-i18n="Global Quick Reply Sets">Global Quick Reply Sets</div>
|
||||
<div class="qr--actions">
|
||||
<div class="qr--setListAdd menu_button menu_button_icon fa-solid fa-plus" id="qr--global-setListAdd" title="Add quick reply set"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qr--global-setList" class="qr--setList"></div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="qr--chat">
|
||||
<div class="qr--head">
|
||||
<div class="qr--title" data-i18n="Chat Quick Reply Sets">Chat Quick Reply Sets</div>
|
||||
<div class="qr--actions">
|
||||
<div class="qr--setListAdd menu_button menu_button_icon fa-solid fa-plus" id="qr--chat-setListAdd" title="Add quick reply set"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qr--chat-setList" class="qr--setList"></div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="qr--character">
|
||||
<div class="qr--head">
|
||||
<div class="qr--title" data-i18n="Character Quick Reply Sets">Character Quick Reply Sets</div>
|
||||
<small data-i18n="(Private)">(Private)</small>
|
||||
<div class="qr--actions">
|
||||
<div class="qr--setListAdd menu_button menu_button_icon fa-solid fa-plus" id="qr--character-setListAdd" title="Add quick reply set"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qr--character-setList" class="qr--setList"></div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div id="qr--editor">
|
||||
<div class="qr--head">
|
||||
<div class="qr--title" data-i18n="Edit Quick Replies">Edit Quick Replies</div>
|
||||
<div class="qr--actions">
|
||||
<select id="qr--set" class="text_pole"></select>
|
||||
<div class="qr--add menu_button menu_button_icon fa-solid fa-pencil" id="qr--set-rename" title="Rename quick reply set"></div>
|
||||
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-new" title="Create new quick reply set"></div>
|
||||
<div class="qr--add menu_button menu_button_icon fa-solid fa-file-import" id="qr--set-import" title="Import quick reply set"></div>
|
||||
<input type="file" id="qr--set-importFile" accept=".json" hidden>
|
||||
<div class="qr--add menu_button menu_button_icon fa-solid fa-file-export" id="qr--set-export" title="Export quick reply set"></div>
|
||||
<div class="qr-add menu_button menu_button_icon fa-solid fa-paste" id="qr--set-duplicate" title="Duplicate quick reply set"></div>
|
||||
<div class="qr--del menu_button menu_button_icon fa-solid fa-trash redWarningBG" id="qr--set-delete" title="Delete quick reply set"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qr--set-settings">
|
||||
<label class="flex-container">
|
||||
<input type="checkbox" id="qr--disableSend"> <span data-i18n="Disable Send (Insert Into Input Field)">Disable send (insert into input field)</span>
|
||||
</label>
|
||||
<label class="flex-container">
|
||||
<input type="checkbox" id="qr--placeBeforeInput"> <span data-i18n="Place Quick Reply Before Input">Place quick reply before input</span>
|
||||
</label>
|
||||
<label class="flex-container" id="qr--injectInputContainer">
|
||||
<input type="checkbox" id="qr--injectInput"> <span><span data-i18n="Inject user input automatically">Inject user input automatically</span> <small><span data-i18n="(if disabled, use ">(if disabled, use</span><code>{{input}}</code> <span data-i18n="macro for manual injection)">macro for manual injection)</span></small></span>
|
||||
</label>
|
||||
<div class="flex-container alignItemsCenter">
|
||||
<toolcool-color-picker id="qr--color"></toolcool-color-picker>
|
||||
<div class="menu_button" id="qr--colorClear">Clear</div>
|
||||
<span data-i18n="Color">Color</span>
|
||||
</div>
|
||||
<label class="flex-container" id="qr--onlyBorderColorContainer">
|
||||
<input type="checkbox" id="qr--onlyBorderColor"> <span data-i18n="Only apply color as accent">Only apply color as accent</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="qr--set-qrList" class="qr--qrList"></div>
|
||||
<div class="qr--set-qrListActions">
|
||||
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-add" title="Add quick reply"></div>
|
||||
<div class="qr--paste menu_button menu_button_icon fa-solid fa-paste" id="qr--set-paste" title="Paste quick reply from clipboard"></div>
|
||||
<div class="qr--import menu_button menu_button_icon fa-solid fa-file-import" id="qr--set-importQr" title="Import quick reply from file"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
324
web-app/public/scripts/extensions/quick-reply/index.js
Normal file
324
web-app/public/scripts/extensions/quick-reply/index.js
Normal file
@@ -0,0 +1,324 @@
|
||||
import { chat, chat_metadata, eventSource, event_types, getRequestHeaders, this_chid, characters } from '../../../script.js';
|
||||
import { extension_settings } from '../../extensions.js';
|
||||
import { QuickReplyApi } from './api/QuickReplyApi.js';
|
||||
import { AutoExecuteHandler } from './src/AutoExecuteHandler.js';
|
||||
import { QuickReply } from './src/QuickReply.js';
|
||||
import { QuickReplyConfig } from './src/QuickReplyConfig.js';
|
||||
import { QuickReplySet } from './src/QuickReplySet.js';
|
||||
import { QuickReplySettings } from './src/QuickReplySettings.js';
|
||||
import { SlashCommandHandler } from './src/SlashCommandHandler.js';
|
||||
import { ButtonUi } from './src/ui/ButtonUi.js';
|
||||
import { SettingsUi } from './src/ui/SettingsUi.js';
|
||||
import { debounceAsync } from '../../utils.js';
|
||||
import { selected_group } from '../../group-chats.js';
|
||||
export { debounceAsync };
|
||||
|
||||
|
||||
|
||||
|
||||
const _VERBOSE = true;
|
||||
export const debug = (...msg) => _VERBOSE ? console.debug('[QR2]', ...msg) : null;
|
||||
export const log = (...msg) => _VERBOSE ? console.log('[QR2]', ...msg) : null;
|
||||
export const warn = (...msg) => _VERBOSE ? console.warn('[QR2]', ...msg) : null;
|
||||
|
||||
|
||||
const defaultConfig = {
|
||||
setList: [{
|
||||
set: 'Default',
|
||||
isVisible: true,
|
||||
}],
|
||||
};
|
||||
|
||||
const defaultSettings = {
|
||||
isEnabled: false,
|
||||
isCombined: false,
|
||||
config: defaultConfig,
|
||||
};
|
||||
|
||||
|
||||
/** @type {Boolean}*/
|
||||
let isReady = false;
|
||||
/** @type {Function[]}*/
|
||||
let executeQueue = [];
|
||||
/** @type {string}*/
|
||||
let lastCharId;
|
||||
/** @type {QuickReplySettings}*/
|
||||
let settings;
|
||||
/** @type {SettingsUi} */
|
||||
let manager;
|
||||
/** @type {ButtonUi} */
|
||||
let buttons;
|
||||
/** @type {AutoExecuteHandler} */
|
||||
let autoExec;
|
||||
/** @type {QuickReplyApi} */
|
||||
export let quickReplyApi;
|
||||
|
||||
|
||||
|
||||
|
||||
const loadSets = async () => {
|
||||
const response = await fetch('/api/settings/get', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const setList = (await response.json()).quickReplyPresets ?? [];
|
||||
for (const set of setList) {
|
||||
if (set.version !== 2) {
|
||||
// migrate old QR set
|
||||
set.version = 2;
|
||||
set.disableSend = set.quickActionEnabled ?? false;
|
||||
set.placeBeforeInput = set.placeBeforeInputEnabled ?? false;
|
||||
set.injectInput = set.AutoInputInject ?? false;
|
||||
set.qrList = set.quickReplySlots.map((slot,idx)=>{
|
||||
const qr = {};
|
||||
qr.id = idx + 1;
|
||||
qr.label = slot.label ?? '';
|
||||
qr.title = slot.title ?? '';
|
||||
qr.message = slot.mes ?? '';
|
||||
qr.isHidden = slot.hidden ?? false;
|
||||
qr.executeOnStartup = slot.autoExecute_appStartup ?? false;
|
||||
qr.executeOnUser = slot.autoExecute_userMessage ?? false;
|
||||
qr.executeOnAi = slot.autoExecute_botMessage ?? false;
|
||||
qr.executeOnChatChange = slot.autoExecute_chatLoad ?? false;
|
||||
qr.executeOnGroupMemberDraft = slot.autoExecute_groupMemberDraft ?? false;
|
||||
qr.executeOnNewChat = slot.autoExecute_newChat ?? false;
|
||||
qr.executeBeforeGeneration = slot.autoExecute_beforeGeneration ?? false;
|
||||
qr.automationId = slot.automationId ?? '';
|
||||
qr.contextList = (slot.contextMenu ?? []).map(it=>({
|
||||
set: it.preset,
|
||||
isChained: it.chain,
|
||||
}));
|
||||
return qr;
|
||||
});
|
||||
}
|
||||
if (set.version == 2) {
|
||||
QuickReplySet.list.push(QuickReplySet.from(JSON.parse(JSON.stringify(set))));
|
||||
}
|
||||
}
|
||||
// need to load QR lists after all sets are loaded to be able to resolve context menu entries
|
||||
setList.forEach((set, idx)=>{
|
||||
QuickReplySet.list[idx].qrList = set.qrList.map(it=>QuickReply.from(it));
|
||||
QuickReplySet.list[idx].init();
|
||||
});
|
||||
log('sets: ', QuickReplySet.list);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!extension_settings.quickReplyV2) {
|
||||
if (!extension_settings.quickReply) {
|
||||
extension_settings.quickReplyV2 = defaultSettings;
|
||||
} else {
|
||||
extension_settings.quickReplyV2 = {
|
||||
isEnabled: extension_settings.quickReply.quickReplyEnabled ?? false,
|
||||
isCombined: false,
|
||||
isPopout: false,
|
||||
config: {
|
||||
setList: [{
|
||||
set: extension_settings.quickReply.selectedPreset ?? extension_settings.quickReply.name ?? 'Default',
|
||||
isVisible: true,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
try {
|
||||
settings = QuickReplySettings.from(extension_settings.quickReplyV2);
|
||||
settings.config.scope = 'global';
|
||||
settings.config.onUpdate = () => settings.save();
|
||||
} catch (ex) {
|
||||
settings = QuickReplySettings.from(defaultSettings);
|
||||
}
|
||||
};
|
||||
|
||||
const executeIfReadyElseQueue = async (functionToCall, args) => {
|
||||
if (isReady) {
|
||||
log('calling', { functionToCall, args });
|
||||
await functionToCall(...args);
|
||||
} else {
|
||||
log('queueing', { functionToCall, args });
|
||||
executeQueue.push(async()=>await functionToCall(...args));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCharChange = () => {
|
||||
if (lastCharId === this_chid) return;
|
||||
|
||||
// Unload the old character's config and update the character ID cache.
|
||||
settings.charConfig = null;
|
||||
lastCharId = this_chid;
|
||||
|
||||
// If no character is loaded, there's nothing more to do.
|
||||
/** @type {Character} */
|
||||
const character = characters[this_chid];
|
||||
if (!character || selected_group) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the character-specific config from the local settings storage.
|
||||
let charConfig = settings.characterConfigs[character.avatar];
|
||||
|
||||
// If no config exists for this character, create a new one.
|
||||
if (!charConfig) {
|
||||
charConfig = QuickReplyConfig.from({ setList: [] });
|
||||
settings.characterConfigs[character.avatar] = charConfig;
|
||||
}
|
||||
|
||||
charConfig.scope = 'character';
|
||||
// The main settings save function will handle persistence.
|
||||
charConfig.onUpdate = () => settings.save();
|
||||
settings.charConfig = charConfig;
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
await loadSets();
|
||||
await loadSettings();
|
||||
log('settings: ', settings);
|
||||
|
||||
manager = new SettingsUi(settings);
|
||||
document.querySelector('#qr_container').append(await manager.render());
|
||||
|
||||
buttons = new ButtonUi(settings);
|
||||
buttons.show();
|
||||
settings.onSave = ()=>buttons.refresh();
|
||||
|
||||
window['executeQuickReplyByName'] = async(name, args = {}, options = {}) => {
|
||||
let qr = [
|
||||
...settings.config.setList,
|
||||
...(settings.chatConfig?.setList ?? []),
|
||||
...(settings.charConfig?.setList ?? []),
|
||||
]
|
||||
.map(it => it.set.qrList)
|
||||
.flat()
|
||||
.find(it=>it.label == name)
|
||||
;
|
||||
if (!qr) {
|
||||
let [setName, ...qrName] = name.split('.');
|
||||
qrName = qrName.join('.');
|
||||
let qrs = QuickReplySet.get(setName);
|
||||
if (qrs) {
|
||||
qr = qrs.qrList.find(it=>it.label == qrName);
|
||||
}
|
||||
}
|
||||
if (qr && qr.onExecute) {
|
||||
return await qr.execute(args, false, true, options);
|
||||
} else {
|
||||
throw new Error(`No Quick Reply found for "${name}".`);
|
||||
}
|
||||
};
|
||||
|
||||
quickReplyApi = new QuickReplyApi(settings, manager);
|
||||
const slash = new SlashCommandHandler(quickReplyApi);
|
||||
slash.init();
|
||||
autoExec = new AutoExecuteHandler(settings);
|
||||
|
||||
eventSource.on(event_types.APP_READY, async()=>await finalizeInit());
|
||||
|
||||
globalThis.quickReplyApi = quickReplyApi;
|
||||
};
|
||||
const finalizeInit = async () => {
|
||||
debug('executing startup');
|
||||
await autoExec.handleStartup();
|
||||
debug('/executing startup');
|
||||
|
||||
debug(`executing queue (${executeQueue.length} items)`);
|
||||
while (executeQueue.length > 0) {
|
||||
const func = executeQueue.shift();
|
||||
await func();
|
||||
}
|
||||
debug('/executing queue');
|
||||
isReady = true;
|
||||
debug('READY');
|
||||
};
|
||||
await init();
|
||||
|
||||
const purgeCharacterQuickReplySets = ({ character }) => {
|
||||
// Remove the character's Quick Reply Sets from the settings.
|
||||
const avatar = character?.avatar;
|
||||
if (avatar && avatar in settings.characterConfigs) {
|
||||
log(`Purging Quick Reply Sets for character: ${avatar}`);
|
||||
delete settings.characterConfigs[avatar];
|
||||
settings.save();
|
||||
}
|
||||
};
|
||||
|
||||
const updateCharacterQuickReplySets = (oldAvatar, newAvatar) => {
|
||||
// Update the character's Quick Reply Sets in the settings.
|
||||
if (oldAvatar && newAvatar && oldAvatar !== newAvatar) {
|
||||
log(`Updating Quick Reply Sets for character: ${oldAvatar} -> ${newAvatar}`);
|
||||
if (settings.characterConfigs[oldAvatar]) {
|
||||
settings.characterConfigs[newAvatar] = settings.characterConfigs[oldAvatar];
|
||||
delete settings.characterConfigs[oldAvatar];
|
||||
settings.save();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onChatChanged = async (chatIdx) => {
|
||||
log('CHAT_CHANGED', chatIdx);
|
||||
|
||||
handleCharChange();
|
||||
|
||||
if (chatIdx) {
|
||||
const chatConfig = QuickReplyConfig.from(chat_metadata.quickReply ?? {});
|
||||
chatConfig.scope = 'chat';
|
||||
chatConfig.onUpdate = () => settings.save();
|
||||
settings.chatConfig = chatConfig;
|
||||
} else {
|
||||
settings.chatConfig = null;
|
||||
}
|
||||
manager.rerender();
|
||||
buttons.refresh();
|
||||
|
||||
await autoExec.handleChatChanged();
|
||||
};
|
||||
eventSource.on(event_types.CHAT_CHANGED, (...args)=>executeIfReadyElseQueue(onChatChanged, args));
|
||||
eventSource.on(event_types.CHARACTER_DELETED, purgeCharacterQuickReplySets);
|
||||
eventSource.on(event_types.CHARACTER_RENAMED, updateCharacterQuickReplySets);
|
||||
|
||||
const onUserMessage = async () => {
|
||||
await autoExec.handleUser();
|
||||
};
|
||||
eventSource.makeFirst(event_types.USER_MESSAGE_RENDERED, (...args)=>executeIfReadyElseQueue(onUserMessage, args));
|
||||
|
||||
const onAiMessage = async (messageId) => {
|
||||
if (['...'].includes(chat[messageId]?.mes)) {
|
||||
log('QR auto-execution suppressed for swiped message');
|
||||
return;
|
||||
}
|
||||
|
||||
await autoExec.handleAi();
|
||||
};
|
||||
eventSource.makeFirst(event_types.CHARACTER_MESSAGE_RENDERED, (...args)=>executeIfReadyElseQueue(onAiMessage, args));
|
||||
|
||||
const onGroupMemberDraft = async () => {
|
||||
await autoExec.handleGroupMemberDraft();
|
||||
};
|
||||
eventSource.on(event_types.GROUP_MEMBER_DRAFTED, (...args) => executeIfReadyElseQueue(onGroupMemberDraft, args));
|
||||
|
||||
const onWIActivation = async (entries) => {
|
||||
await autoExec.handleWIActivation(entries);
|
||||
};
|
||||
eventSource.on(event_types.WORLD_INFO_ACTIVATED, (...args) => executeIfReadyElseQueue(onWIActivation, args));
|
||||
|
||||
const onNewChat = async () => {
|
||||
await autoExec.handleNewChat();
|
||||
};
|
||||
eventSource.on(event_types.CHAT_CREATED, (...args) => executeIfReadyElseQueue(onNewChat, args));
|
||||
eventSource.on(event_types.GROUP_CHAT_CREATED, (...args) => executeIfReadyElseQueue(onNewChat, args));
|
||||
|
||||
const onBeforeGeneration = async (_generationType, _options = {}, isDryRun = false) => {
|
||||
if (isDryRun) {
|
||||
log('Before-generation hook skipped due to dryRun.');
|
||||
return;
|
||||
}
|
||||
if (selected_group && this_chid === undefined) {
|
||||
log('Before-generation hook skipped for event before group wrapper.');
|
||||
return;
|
||||
}
|
||||
await autoExec.handleBeforeGeneration();
|
||||
};
|
||||
eventSource.on(event_types.GENERATION_AFTER_COMMANDS, (...args) => executeIfReadyElseQueue(onBeforeGeneration, args));
|
||||
11
web-app/public/scripts/extensions/quick-reply/manifest.json
Normal file
11
web-app/public/scripts/extensions/quick-reply/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"display_name": "Quick Replies",
|
||||
"loading_order": 12,
|
||||
"requires": [],
|
||||
"optional": [],
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "RossAscends#1779",
|
||||
"version": "2.0.0",
|
||||
"homePage": "https://github.com/SillyTavern/SillyTavern"
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { warn } from '../index.js';
|
||||
import { QuickReply } from './QuickReply.js';
|
||||
import { QuickReplySettings } from './QuickReplySettings.js';
|
||||
|
||||
export class AutoExecuteHandler {
|
||||
/** @type {QuickReplySettings} */ settings;
|
||||
|
||||
/** @type {Boolean[]}*/ preventAutoExecuteStack = [];
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/** @type {QuickReplySettings} */settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
|
||||
checkExecute() {
|
||||
return this.settings.isEnabled && !this.preventAutoExecuteStack.slice(-1)[0];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async performAutoExecute(/** @type {QuickReply[]} */qrList) {
|
||||
for (const qr of qrList) {
|
||||
this.preventAutoExecuteStack.push(qr.preventAutoExecute);
|
||||
try {
|
||||
await qr.execute({ isAutoExecute:true });
|
||||
} catch (ex) {
|
||||
warn(ex);
|
||||
} finally {
|
||||
this.preventAutoExecuteStack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getCommands(eventName) {
|
||||
const getFromConfig = (config) => {
|
||||
// This safely handles cases where a link exists but the set hasn't been loaded (link.set is null)
|
||||
return config?.setList?.map(link => link.set ? link.set.qrList.filter(qr => qr[eventName]) : [])?.flat() ?? [];
|
||||
};
|
||||
return [
|
||||
...getFromConfig(this.settings.config),
|
||||
...getFromConfig(this.settings.chatConfig),
|
||||
...getFromConfig(this.settings.charConfig),
|
||||
];
|
||||
}
|
||||
|
||||
async handleStartup() {
|
||||
if (!this.checkExecute()) return;
|
||||
await this.performAutoExecute(this.getCommands('executeOnStartup'));
|
||||
}
|
||||
|
||||
async handleUser() {
|
||||
if (!this.checkExecute()) return;
|
||||
await this.performAutoExecute(this.getCommands('executeOnUser'));
|
||||
}
|
||||
|
||||
async handleAi() {
|
||||
if (!this.checkExecute()) return;
|
||||
await this.performAutoExecute(this.getCommands('executeOnAi'));
|
||||
}
|
||||
|
||||
async handleChatChanged() {
|
||||
if (!this.checkExecute()) return;
|
||||
await this.performAutoExecute(this.getCommands('executeOnChatChange'));
|
||||
}
|
||||
|
||||
async handleGroupMemberDraft() {
|
||||
if (!this.checkExecute()) return;
|
||||
await this.performAutoExecute(this.getCommands('executeOnGroupMemberDraft'));
|
||||
}
|
||||
|
||||
async handleNewChat() {
|
||||
if (!this.checkExecute()) return;
|
||||
await this.performAutoExecute(this.getCommands('executeOnNewChat'));
|
||||
}
|
||||
|
||||
async handleBeforeGeneration() {
|
||||
if (!this.checkExecute()) return;
|
||||
await this.performAutoExecute(this.getCommands('executeBeforeGeneration'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any[]} entries Set of activated entries
|
||||
*/
|
||||
async handleWIActivation(entries) {
|
||||
if (!this.checkExecute() || !Array.isArray(entries) || entries.length === 0) return;
|
||||
const automationIds = entries.map(entry => entry.automationId).filter(Boolean);
|
||||
if (automationIds.length === 0) return;
|
||||
|
||||
const getFromConfig = (config) => {
|
||||
return config?.setList
|
||||
?.map(link => link.set ? link.set.qrList.filter(qr => qr.automationId && automationIds.includes(qr.automationId)) : [])
|
||||
?.flat() ?? [];
|
||||
};
|
||||
|
||||
const qrList = [
|
||||
...getFromConfig(this.settings.config),
|
||||
...getFromConfig(this.settings.chatConfig),
|
||||
...getFromConfig(this.settings.charConfig),
|
||||
];
|
||||
|
||||
await this.performAutoExecute(qrList);
|
||||
}
|
||||
}
|
||||
1934
web-app/public/scripts/extensions/quick-reply/src/QuickReply.js
Normal file
1934
web-app/public/scripts/extensions/quick-reply/src/QuickReply.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
import { getSortableDelay } from '../../../utils.js';
|
||||
import { QuickReplySetLink } from './QuickReplySetLink.js';
|
||||
import { QuickReplySet } from './QuickReplySet.js';
|
||||
|
||||
export class QuickReplyConfig {
|
||||
/**@type {QuickReplySetLink[]}*/ setList = [];
|
||||
/**@type {'global'|'chat'|'character'}*/ scope;
|
||||
|
||||
/**@type {Function}*/ onUpdate;
|
||||
/**@type {Function}*/ onRequestEditSet;
|
||||
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ setListDom;
|
||||
|
||||
|
||||
|
||||
|
||||
static from(props) {
|
||||
props.setList = props.setList?.map(it=>QuickReplySetLink.from(it))?.filter(it=>it.set) ?? [];
|
||||
const instance = Object.assign(new this(), props);
|
||||
instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
init() {
|
||||
this.setList.forEach(it=>this.hookQuickReplyLink(it));
|
||||
}
|
||||
|
||||
|
||||
hasSet(qrs) {
|
||||
return this.setList.find(it=>it.set == qrs) != null;
|
||||
}
|
||||
addSet(qrs, isVisible = true) {
|
||||
if (!this.hasSet(qrs)) {
|
||||
const qrl = new QuickReplySetLink();
|
||||
qrl.set = qrs;
|
||||
qrl.isVisible = isVisible;
|
||||
this.hookQuickReplyLink(qrl);
|
||||
this.setList.push(qrl);
|
||||
this.setListDom.append(qrl.renderSettings(this.setList.length - 1));
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
removeSet(qrs) {
|
||||
const idx = this.setList.findIndex(it=>it.set == qrs);
|
||||
if (idx > -1) {
|
||||
this.setList.splice(idx, 1);
|
||||
this.update();
|
||||
this.updateSetListDom();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
renderSettingsInto(/**@type {HTMLElement}*/root) {
|
||||
/**@type {HTMLElement}*/
|
||||
this.setListDom = root.querySelector('.qr--setList');
|
||||
root.querySelector('.qr--setListAdd').addEventListener('click', ()=>{
|
||||
const newSet = QuickReplySet.list.find(qr=>!this.setList.find(qrl=>qrl.set == qr));
|
||||
if (newSet) {
|
||||
this.addSet(newSet);
|
||||
} else {
|
||||
toastr.warning('All existing QR Sets have already been added.');
|
||||
}
|
||||
});
|
||||
this.updateSetListDom();
|
||||
}
|
||||
updateSetListDom() {
|
||||
this.setListDom.innerHTML = '';
|
||||
// @ts-ignore
|
||||
$(this.setListDom).sortable({
|
||||
delay: getSortableDelay(),
|
||||
stop: ()=>this.onSetListSort(),
|
||||
});
|
||||
this.setList.filter(it=>!it.set.isDeleted).forEach((qrl,idx)=>this.setListDom.append(qrl.renderSettings(idx)));
|
||||
}
|
||||
|
||||
|
||||
onSetListSort() {
|
||||
this.setList = Array.from(this.setListDom.children).map((it,idx)=>{
|
||||
const qrl = this.setList[Number(it.getAttribute('data-order'))];
|
||||
qrl.index = idx;
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return qrl;
|
||||
});
|
||||
this.update();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {QuickReplySetLink} qrl
|
||||
*/
|
||||
hookQuickReplyLink(qrl) {
|
||||
qrl.onDelete = ()=>this.deleteQuickReplyLink(qrl);
|
||||
qrl.onUpdate = ()=>this.update();
|
||||
qrl.onRequestEditSet = ()=>this.requestEditSet(qrl.set);
|
||||
}
|
||||
|
||||
deleteQuickReplyLink(qrl) {
|
||||
this.setList.splice(this.setList.indexOf(qrl), 1);
|
||||
this.update();
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.onUpdate) {
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
|
||||
requestEditSet(qrs) {
|
||||
if (this.onRequestEditSet) {
|
||||
this.onRequestEditSet(qrs);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
setList: this.setList,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { getRequestHeaders, substituteParams } from '../../../../script.js';
|
||||
import { Popup, POPUP_RESULT, POPUP_TYPE } from '../../../popup.js';
|
||||
import { executeSlashCommandsOnChatInput, executeSlashCommandsWithOptions } from '../../../slash-commands.js';
|
||||
import { SlashCommandScope } from '../../../slash-commands/SlashCommandScope.js';
|
||||
import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js';
|
||||
import { debounceAsync, warn } from '../index.js';
|
||||
import { QuickReply } from './QuickReply.js';
|
||||
|
||||
export class QuickReplySet {
|
||||
/**@type {QuickReplySet[]}*/ static list = [];
|
||||
|
||||
/**
|
||||
* @param {Partial<QuickReplySet>} props
|
||||
* @returns {QuickReplySet}
|
||||
*/
|
||||
static from(props) {
|
||||
props.qrList = []; //props.qrList?.map(it=>QuickReply.from(it));
|
||||
const instance = Object.assign(new this(), props);
|
||||
// instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name - name of the QuickReplySet
|
||||
*/
|
||||
static get(name) {
|
||||
return this.list.find(it=>it.name == name);
|
||||
}
|
||||
|
||||
/**@type {string}*/ name;
|
||||
/**@type {'global'|'chat'|'character'}*/ scope = 'global';
|
||||
/**@type {boolean}*/ disableSend = false;
|
||||
/**@type {boolean}*/ placeBeforeInput = false;
|
||||
/**@type {boolean}*/ injectInput = false;
|
||||
/**@type {string}*/ color = 'transparent';
|
||||
/**@type {boolean}*/ onlyBorderColor = false;
|
||||
/**@type {QuickReply[]}*/ qrList = [];
|
||||
/**@type {number}*/ idIndex = 0;
|
||||
/**@type {boolean}*/ isDeleted = false;
|
||||
/**@type {function}*/ save;
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ settingsDom;
|
||||
|
||||
constructor() {
|
||||
this.save = debounceAsync(()=>this.performSave(), 200);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.qrList.forEach(qr=>this.hookQuickReply(qr));
|
||||
}
|
||||
|
||||
unrender() {
|
||||
this.dom?.remove();
|
||||
this.dom = null;
|
||||
}
|
||||
render() {
|
||||
this.unrender();
|
||||
if (!this.dom) {
|
||||
const root = document.createElement('div'); {
|
||||
this.dom = root;
|
||||
root.classList.add('qr--buttons');
|
||||
this.updateColor();
|
||||
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
|
||||
root.append(qr.render());
|
||||
});
|
||||
}
|
||||
}
|
||||
return this.dom;
|
||||
}
|
||||
rerender() {
|
||||
if (!this.dom) return;
|
||||
this.dom.innerHTML = '';
|
||||
this.qrList.filter(qr=>!qr.isHidden).forEach(qr=>{
|
||||
this.dom.append(qr.render());
|
||||
});
|
||||
}
|
||||
updateColor() {
|
||||
if (!this.dom) return;
|
||||
if (this.color && this.color != 'transparent') {
|
||||
this.dom.style.setProperty('--qr--color', this.color);
|
||||
this.dom.classList.add('qr--color');
|
||||
if (this.onlyBorderColor) {
|
||||
this.dom.classList.add('qr--borderColor');
|
||||
} else {
|
||||
this.dom.classList.remove('qr--borderColor');
|
||||
}
|
||||
} else {
|
||||
this.dom.style.setProperty('--qr--color', 'transparent');
|
||||
this.dom.classList.remove('qr--color');
|
||||
this.dom.classList.remove('qr--borderColor');
|
||||
}
|
||||
}
|
||||
|
||||
renderSettings() {
|
||||
if (!this.settingsDom) {
|
||||
this.settingsDom = document.createElement('div'); {
|
||||
this.settingsDom.classList.add('qr--set-qrListContents');
|
||||
this.qrList.forEach((qr,idx)=>{
|
||||
this.renderSettingsItem(qr, idx);
|
||||
});
|
||||
}
|
||||
}
|
||||
return this.settingsDom;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {QuickReply} qr
|
||||
* @param {number} idx
|
||||
*/
|
||||
renderSettingsItem(qr, idx) {
|
||||
this.settingsDom.append(qr.renderSettings(idx));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {QuickReply} qr
|
||||
*/
|
||||
async debug(qr) {
|
||||
const parser = new SlashCommandParser();
|
||||
const closure = parser.parse(qr.message, true, [], qr.abortController, qr.debugController);
|
||||
closure.source = `${this.name}.${qr.label}`;
|
||||
closure.onProgress = (done, total) => qr.updateEditorProgress(done, total);
|
||||
closure.scope.setMacro('arg::*', '');
|
||||
return (await closure.execute())?.pipe;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {QuickReply} qr The QR to execute.
|
||||
* @param {object} options
|
||||
* @param {string} [options.message] (null) altered message to be used
|
||||
* @param {boolean} [options.isAutoExecute] (false) whether the execution is triggered by auto execute
|
||||
* @param {boolean} [options.isEditor] (false) whether the execution is triggered by the QR editor
|
||||
* @param {boolean} [options.isRun] (false) whether the execution is triggered by /run or /: (window.executeQuickReplyByName)
|
||||
* @param {SlashCommandScope} [options.scope] (null) scope to be used when running the command
|
||||
* @param {import('../../../slash-commands.js').ExecuteSlashCommandsOptions} [options.executionOptions] ({}) further execution options
|
||||
* @returns
|
||||
*/
|
||||
async executeWithOptions(qr, options = {}) {
|
||||
options = Object.assign({
|
||||
message:null,
|
||||
isAutoExecute:false,
|
||||
isEditor:false,
|
||||
isRun:false,
|
||||
scope:null,
|
||||
executionOptions:{},
|
||||
}, options);
|
||||
const execOptions = options.executionOptions;
|
||||
/**@type {HTMLTextAreaElement}*/
|
||||
const ta = document.querySelector('#send_textarea');
|
||||
const finalMessage = options.message ?? qr.message;
|
||||
let input = ta.value;
|
||||
if (!options.isAutoExecute && !options.isEditor && !options.isRun && this.injectInput && input.length > 0) {
|
||||
if (this.placeBeforeInput) {
|
||||
input = `${finalMessage} ${input}`;
|
||||
} else {
|
||||
input = `${input} ${finalMessage}`;
|
||||
}
|
||||
} else {
|
||||
input = `${finalMessage} `;
|
||||
}
|
||||
|
||||
if (input[0] == '/' && !this.disableSend) {
|
||||
let result;
|
||||
if (options.isAutoExecute || options.isRun) {
|
||||
result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, {
|
||||
handleParserErrors: true,
|
||||
scope: options.scope,
|
||||
source: `${this.name}.${qr.label}`,
|
||||
}));
|
||||
} else if (options.isEditor) {
|
||||
result = await executeSlashCommandsWithOptions(input, Object.assign(execOptions, {
|
||||
handleParserErrors: false,
|
||||
scope: options.scope,
|
||||
abortController: qr.abortController,
|
||||
source: `${this.name}.${qr.label}`,
|
||||
onProgress: (done, total) => qr.updateEditorProgress(done, total),
|
||||
}));
|
||||
} else {
|
||||
result = await executeSlashCommandsOnChatInput(input, Object.assign(execOptions, {
|
||||
scope: options.scope,
|
||||
source: `${this.name}.${qr.label}`,
|
||||
}));
|
||||
}
|
||||
return typeof result === 'object' ? result?.pipe : '';
|
||||
}
|
||||
|
||||
ta.value = substituteParams(input);
|
||||
ta.focus();
|
||||
|
||||
if (!this.disableSend) {
|
||||
// @ts-ignore
|
||||
document.querySelector('#send_but').click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QuickReply} qr
|
||||
* @param {string} [message] - optional altered message to be used
|
||||
* @param {SlashCommandScope} [scope] - optional scope to be used when running the command
|
||||
*/
|
||||
async execute(qr, message = null, isAutoExecute = false, scope = null) {
|
||||
return this.executeWithOptions(qr, {
|
||||
message,
|
||||
isAutoExecute,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
addQuickReply(data = {}) {
|
||||
const id = Math.max(this.idIndex, this.qrList.reduce((max,qr)=>Math.max(max,qr.id),0)) + 1;
|
||||
data.id =
|
||||
this.idIndex = id + 1;
|
||||
const qr = QuickReply.from(data);
|
||||
this.qrList.push(qr);
|
||||
this.hookQuickReply(qr);
|
||||
if (this.settingsDom) {
|
||||
this.renderSettingsItem(qr, this.qrList.length - 1);
|
||||
}
|
||||
if (this.dom) {
|
||||
this.dom.append(qr.render());
|
||||
}
|
||||
this.save();
|
||||
return qr;
|
||||
}
|
||||
|
||||
addQuickReplyFromText(qrJson) {
|
||||
let data;
|
||||
if (qrJson) {
|
||||
try {
|
||||
data = JSON.parse(qrJson ?? '{}');
|
||||
delete data.id;
|
||||
} catch {
|
||||
// not JSON data
|
||||
}
|
||||
if (data) {
|
||||
// JSON data
|
||||
if (data.label === undefined || data.message === undefined) {
|
||||
// not a QR
|
||||
toastr.error('Not a QR.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// no JSON, use plaintext as QR message
|
||||
data = { message: qrJson };
|
||||
}
|
||||
} else {
|
||||
data = {};
|
||||
}
|
||||
const newQr = this.addQuickReply(data);
|
||||
return newQr;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {QuickReply} qr
|
||||
*/
|
||||
hookQuickReply(qr) {
|
||||
// @ts-ignore
|
||||
qr.onDebug = ()=>this.debug(qr);
|
||||
qr.onExecute = (_, options)=>this.executeWithOptions(qr, options);
|
||||
qr.onDelete = ()=>this.removeQuickReply(qr);
|
||||
qr.onUpdate = ()=>this.save();
|
||||
qr.onInsertBefore = (qrJson)=>{
|
||||
this.addQuickReplyFromText(qrJson);
|
||||
const newQr = this.qrList.pop();
|
||||
this.qrList.splice(this.qrList.indexOf(qr), 0, newQr);
|
||||
if (qr.settingsDom) {
|
||||
qr.settingsDom.insertAdjacentElement('beforebegin', newQr.settingsDom);
|
||||
}
|
||||
this.save();
|
||||
};
|
||||
qr.onTransfer = async()=>{
|
||||
/**@type {HTMLSelectElement} */
|
||||
let sel;
|
||||
let isCopy = false;
|
||||
const dom = document.createElement('div'); {
|
||||
dom.classList.add('qr--transferModal');
|
||||
const title = document.createElement('h3'); {
|
||||
title.textContent = 'Transfer Quick Reply';
|
||||
dom.append(title);
|
||||
}
|
||||
const subTitle = document.createElement('h4'); {
|
||||
const entryName = qr.label;
|
||||
const bookName = this.name;
|
||||
subTitle.textContent = `${bookName}: ${entryName}`;
|
||||
dom.append(subTitle);
|
||||
}
|
||||
sel = document.createElement('select'); {
|
||||
sel.classList.add('qr--transferSelect');
|
||||
sel.setAttribute('autofocus', '1');
|
||||
const noOpt = document.createElement('option'); {
|
||||
noOpt.value = '';
|
||||
noOpt.textContent = '-- Select QR Set --';
|
||||
sel.append(noOpt);
|
||||
}
|
||||
for (const qrs of QuickReplySet.list) {
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
sel.append(opt);
|
||||
}
|
||||
}
|
||||
sel.addEventListener('keyup', (evt)=>{
|
||||
if (evt.key == 'Shift') {
|
||||
// @ts-ignore
|
||||
(dlg.dom ?? dlg.dlg).classList.remove('qr--isCopy');
|
||||
return;
|
||||
}
|
||||
});
|
||||
sel.addEventListener('keydown', (evt)=>{
|
||||
if (evt.key == 'Shift') {
|
||||
// @ts-ignore
|
||||
(dlg.dom ?? dlg.dlg).classList.add('qr--isCopy');
|
||||
return;
|
||||
}
|
||||
if (!evt.ctrlKey && !evt.altKey && evt.key == 'Enter') {
|
||||
evt.preventDefault();
|
||||
if (evt.shiftKey) isCopy = true;
|
||||
dlg.completeAffirmative();
|
||||
}
|
||||
});
|
||||
dom.append(sel);
|
||||
}
|
||||
const hintP = document.createElement('p'); {
|
||||
const hint = document.createElement('small'); {
|
||||
hint.textContent = 'Type or arrows to select QR Set. Enter to transfer. Shift+Enter to copy.';
|
||||
hintP.append(hint);
|
||||
}
|
||||
dom.append(hintP);
|
||||
}
|
||||
}
|
||||
const dlg = new Popup(dom, POPUP_TYPE.CONFIRM, null, { okButton:'Transfer', cancelButton:'Cancel' });
|
||||
const copyBtn = document.createElement('div'); {
|
||||
copyBtn.classList.add('qr--copy');
|
||||
copyBtn.classList.add('menu_button');
|
||||
copyBtn.textContent = 'Copy';
|
||||
copyBtn.addEventListener('click', ()=>{
|
||||
isCopy = true;
|
||||
dlg.completeAffirmative();
|
||||
});
|
||||
// @ts-ignore
|
||||
(dlg.ok ?? dlg.okButton).insertAdjacentElement('afterend', copyBtn);
|
||||
}
|
||||
const prom = dlg.show();
|
||||
sel.focus();
|
||||
await prom;
|
||||
if (dlg.result == POPUP_RESULT.AFFIRMATIVE) {
|
||||
const qrs = QuickReplySet.list.find(it=>it.name == sel.value);
|
||||
qrs.addQuickReply(qr.toJSON());
|
||||
if (!isCopy) {
|
||||
qr.delete();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
removeQuickReply(qr) {
|
||||
this.qrList.splice(this.qrList.indexOf(qr), 1);
|
||||
this.save();
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
version: 2,
|
||||
name: this.name,
|
||||
disableSend: this.disableSend,
|
||||
placeBeforeInput: this.placeBeforeInput,
|
||||
injectInput: this.injectInput,
|
||||
color: this.color,
|
||||
onlyBorderColor: this.onlyBorderColor,
|
||||
qrList: this.qrList,
|
||||
idIndex: this.idIndex,
|
||||
};
|
||||
}
|
||||
|
||||
async performSave() {
|
||||
const response = await fetch('/api/quick-replies/save', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.rerender();
|
||||
} else {
|
||||
warn(`Failed to save Quick Reply Set: ${this.name}`);
|
||||
console.error('QR could not be saved', response);
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
const response = await fetch('/api/quick-replies/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(this),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.unrender();
|
||||
const idx = QuickReplySet.list.indexOf(this);
|
||||
if (idx > -1) {
|
||||
QuickReplySet.list.splice(idx, 1);
|
||||
this.isDeleted = true;
|
||||
} else {
|
||||
warn(`Deleted Quick Reply Set was not found in the list of sets: ${this.name}`);
|
||||
}
|
||||
} else {
|
||||
warn(`Failed to delete Quick Reply Set: ${this.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { QuickReplySet } from './QuickReplySet.js';
|
||||
|
||||
export class QuickReplySetLink {
|
||||
static from(props) {
|
||||
props.set = QuickReplySet.get(props.set);
|
||||
/**@type {QuickReplySetLink}*/
|
||||
const instance = Object.assign(new this(), props);
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {QuickReplySet}*/ set;
|
||||
/**@type {Boolean}*/ isVisible = true;
|
||||
|
||||
/**@type {Number}*/ index;
|
||||
|
||||
/**@type {Function}*/ onUpdate;
|
||||
/**@type {Function}*/ onRequestEditSet;
|
||||
/**@type {Function}*/ onDelete;
|
||||
|
||||
/**@type {HTMLElement}*/ settingsDom;
|
||||
|
||||
|
||||
|
||||
|
||||
renderSettings(idx) {
|
||||
this.index = idx;
|
||||
const item = document.createElement('div'); {
|
||||
this.settingsDom = item;
|
||||
item.classList.add('qr--item');
|
||||
item.setAttribute('data-order', String(this.index));
|
||||
const drag = document.createElement('div'); {
|
||||
drag.classList.add('drag-handle');
|
||||
drag.classList.add('ui-sortable-handle');
|
||||
drag.textContent = '☰';
|
||||
item.append(drag);
|
||||
}
|
||||
const set = document.createElement('select'); {
|
||||
set.classList.add('qr--set');
|
||||
// fix for jQuery sortable breaking childrens' touch events
|
||||
set.addEventListener('touchstart', (evt)=>evt.stopPropagation());
|
||||
set.addEventListener('change', ()=>{
|
||||
this.set = QuickReplySet.get(set.value);
|
||||
this.update();
|
||||
});
|
||||
QuickReplySet.list.toSorted((a,b)=>a.name.toLowerCase().localeCompare(b.name.toLowerCase())).forEach(qrs=>{
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
opt.selected = qrs == this.set;
|
||||
set.append(opt);
|
||||
}
|
||||
});
|
||||
item.append(set);
|
||||
}
|
||||
const visible = document.createElement('label'); {
|
||||
visible.classList.add('qr--visible');
|
||||
visible.title = 'Show buttons';
|
||||
const cb = document.createElement('input'); {
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = this.isVisible;
|
||||
cb.addEventListener('click', ()=>{
|
||||
this.isVisible = cb.checked;
|
||||
this.update();
|
||||
});
|
||||
visible.append(cb);
|
||||
}
|
||||
visible.append('Buttons');
|
||||
item.append(visible);
|
||||
}
|
||||
const edit = document.createElement('div'); {
|
||||
edit.classList.add('menu_button');
|
||||
edit.classList.add('menu_button_icon');
|
||||
edit.classList.add('fa-solid');
|
||||
edit.classList.add('fa-pencil');
|
||||
edit.title = 'Edit quick reply set';
|
||||
edit.addEventListener('click', ()=>this.requestEditSet());
|
||||
item.append(edit);
|
||||
}
|
||||
const del = document.createElement('div'); {
|
||||
del.classList.add('qr--del');
|
||||
del.classList.add('menu_button');
|
||||
del.classList.add('menu_button_icon');
|
||||
del.classList.add('fa-solid');
|
||||
del.classList.add('fa-trash-can');
|
||||
del.title = 'Remove quick reply set';
|
||||
del.addEventListener('click', ()=>this.delete());
|
||||
item.append(del);
|
||||
}
|
||||
}
|
||||
return this.settingsDom;
|
||||
}
|
||||
unrenderSettings() {
|
||||
this.settingsDom?.remove();
|
||||
this.settingsDom = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
update() {
|
||||
if (this.onUpdate) {
|
||||
this.onUpdate(this);
|
||||
}
|
||||
}
|
||||
requestEditSet() {
|
||||
if (this.onRequestEditSet) {
|
||||
this.onRequestEditSet(this.set);
|
||||
}
|
||||
}
|
||||
delete() {
|
||||
this.unrenderSettings();
|
||||
if (this.onDelete) {
|
||||
this.onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
set: this.set.name,
|
||||
isVisible: this.isVisible,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { chat_metadata, saveSettingsDebounced } from '../../../../script.js';
|
||||
import { extension_settings, saveMetadataDebounced } from '../../../extensions.js';
|
||||
import { QuickReplyConfig } from './QuickReplyConfig.js';
|
||||
|
||||
export class QuickReplySettings {
|
||||
static from(props) {
|
||||
props.config = QuickReplyConfig.from(props.config);
|
||||
props.characterConfigs = props.characterConfigs ?? {};
|
||||
for (const key of Object.keys(props.characterConfigs)) {
|
||||
props.characterConfigs[key] = QuickReplyConfig.from(props.characterConfigs[key]);
|
||||
}
|
||||
const instance = Object.assign(new this(), props);
|
||||
instance.init();
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {Boolean}*/ isEnabled = false;
|
||||
/**@type {Boolean}*/ isCombined = false;
|
||||
/**@type {Boolean}*/ isPopout = false;
|
||||
/**@type {Boolean}*/ showPopoutButton = true;
|
||||
/**@type {QuickReplyConfig}*/ config;
|
||||
/**@type {{[key:string]: QuickReplyConfig}}*/ characterConfigs = {};
|
||||
/**@type {QuickReplyConfig}*/ _chatConfig;
|
||||
/**@type {QuickReplyConfig}*/ _charConfig;
|
||||
get chatConfig() {
|
||||
return this._chatConfig;
|
||||
}
|
||||
set chatConfig(value) {
|
||||
if (this._chatConfig != value) {
|
||||
this.unhookConfig(this._chatConfig);
|
||||
this._chatConfig = value;
|
||||
this.hookConfig(this._chatConfig);
|
||||
}
|
||||
}
|
||||
get charConfig() {
|
||||
return this._charConfig;
|
||||
}
|
||||
set charConfig(value) {
|
||||
if (this._charConfig != value) {
|
||||
this.unhookConfig(this._charConfig);
|
||||
this._charConfig = value;
|
||||
this.hookConfig(this._charConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**@type {Function}*/ onSave;
|
||||
/**@type {Function}*/ onRequestEditSet;
|
||||
|
||||
|
||||
|
||||
|
||||
init() {
|
||||
this.hookConfig(this.config);
|
||||
this.hookConfig(this.chatConfig);
|
||||
this.hookConfig(this.charConfig);
|
||||
}
|
||||
|
||||
hookConfig(config) {
|
||||
if (config) {
|
||||
config.onUpdate = ()=>this.save();
|
||||
config.onRequestEditSet = (qrs)=>this.requestEditSet(qrs);
|
||||
}
|
||||
}
|
||||
unhookConfig(config) {
|
||||
if (config) {
|
||||
config.onUpdate = null;
|
||||
config.onRequestEditSet = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
save() {
|
||||
extension_settings.quickReplyV2 = this.toJSON();
|
||||
saveSettingsDebounced();
|
||||
if (this.chatConfig) {
|
||||
chat_metadata.quickReply = this.chatConfig.toJSON();
|
||||
saveMetadataDebounced();
|
||||
}
|
||||
if (this.onSave) {
|
||||
this.onSave();
|
||||
}
|
||||
}
|
||||
|
||||
requestEditSet(qrs) {
|
||||
if (this.onRequestEditSet) {
|
||||
this.onRequestEditSet(qrs);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const characterConfigs = {};
|
||||
for (const key of Object.keys(this.characterConfigs)) {
|
||||
if (this.characterConfigs[key]?.setList?.length === 0) {
|
||||
continue;
|
||||
}
|
||||
characterConfigs[key] = this.characterConfigs[key].toJSON();
|
||||
}
|
||||
return {
|
||||
isEnabled: this.isEnabled,
|
||||
isCombined: this.isCombined,
|
||||
isPopout: this.isPopout,
|
||||
showPopoutButton: this.showPopoutButton,
|
||||
config: this.config,
|
||||
characterConfigs,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
163
web-app/public/scripts/extensions/quick-reply/src/ui/ButtonUi.js
Normal file
163
web-app/public/scripts/extensions/quick-reply/src/ui/ButtonUi.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import { animation_duration } from '../../../../../script.js';
|
||||
import { dragElement } from '../../../../RossAscends-mods.js';
|
||||
import { loadMovingUIState } from '../../../../power-user.js';
|
||||
import { QuickReplySettings } from '../QuickReplySettings.js';
|
||||
|
||||
export class ButtonUi {
|
||||
/** @type {QuickReplySettings} */ settings;
|
||||
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ popoutDom;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {QuickReplySettings}*/settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
if (this.settings.isPopout) {
|
||||
return this.renderPopout();
|
||||
}
|
||||
return this.renderBar();
|
||||
}
|
||||
unrender() {
|
||||
this.dom?.remove();
|
||||
this.dom = null;
|
||||
this.popoutDom?.remove();
|
||||
this.popoutDom = null;
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this.settings.isEnabled) return;
|
||||
if (this.settings.isPopout) {
|
||||
document.body.append(this.render());
|
||||
loadMovingUIState();
|
||||
$(this.render()).fadeIn(animation_duration);
|
||||
dragElement($(this.render()));
|
||||
} else {
|
||||
const sendForm = document.querySelector('#send_form');
|
||||
if (sendForm.children.length > 0) {
|
||||
sendForm.children[0].insertAdjacentElement('beforebegin', this.render());
|
||||
} else {
|
||||
sendForm.append(this.render());
|
||||
}
|
||||
}
|
||||
}
|
||||
hide() {
|
||||
this.unrender();
|
||||
}
|
||||
refresh() {
|
||||
this.hide();
|
||||
this.show();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
renderBar() {
|
||||
if (!this.dom) {
|
||||
let buttonHolder;
|
||||
const root = document.createElement('div'); {
|
||||
this.dom = root;
|
||||
buttonHolder = root;
|
||||
root.id = 'qr--bar';
|
||||
root.classList.add('flex-container');
|
||||
root.classList.add('flexGap5');
|
||||
if (this.settings.showPopoutButton) {
|
||||
root.classList.add('popoutVisible');
|
||||
const popout = document.createElement('div'); {
|
||||
popout.id = 'qr--popoutTrigger';
|
||||
popout.classList.add('menu_button');
|
||||
popout.classList.add('fa-solid');
|
||||
popout.classList.add('fa-window-restore');
|
||||
popout.addEventListener('click', ()=>{
|
||||
this.settings.isPopout = true;
|
||||
this.refresh();
|
||||
this.settings.save();
|
||||
});
|
||||
root.append(popout);
|
||||
}
|
||||
}
|
||||
if (this.settings.isCombined) {
|
||||
const buttons = document.createElement('div'); {
|
||||
buttonHolder = buttons;
|
||||
buttons.classList.add('qr--buttons');
|
||||
root.append(buttons);
|
||||
}
|
||||
}
|
||||
[...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? []), ...(this.settings.charConfig?.setList ?? [])]
|
||||
.filter(link=>link.isVisible)
|
||||
.forEach(link=>buttonHolder.append(link.set.render()))
|
||||
;
|
||||
}
|
||||
}
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
renderPopout() {
|
||||
if (!this.popoutDom) {
|
||||
let buttonHolder;
|
||||
const root = document.createElement('div'); {
|
||||
this.popoutDom = root;
|
||||
root.id = 'qr--popout';
|
||||
root.classList.add('qr--popout');
|
||||
root.classList.add('draggable');
|
||||
const head = document.createElement('div'); {
|
||||
head.classList.add('qr--header');
|
||||
root.append(head);
|
||||
const controls = document.createElement('div'); {
|
||||
controls.classList.add('qr--controls');
|
||||
controls.classList.add('panelControlBar');
|
||||
controls.classList.add('flex-container');
|
||||
const drag = document.createElement('div'); {
|
||||
drag.id = 'qr--popoutheader';
|
||||
drag.classList.add('fa-solid');
|
||||
drag.classList.add('fa-grip');
|
||||
drag.classList.add('drag-grabber');
|
||||
drag.classList.add('hoverglow');
|
||||
controls.append(drag);
|
||||
}
|
||||
const close = document.createElement('div'); {
|
||||
close.classList.add('qr--close');
|
||||
close.classList.add('fa-solid');
|
||||
close.classList.add('fa-circle-xmark');
|
||||
close.classList.add('hoverglow');
|
||||
close.addEventListener('click', ()=>{
|
||||
this.settings.isPopout = false;
|
||||
this.refresh();
|
||||
this.settings.save();
|
||||
});
|
||||
controls.append(close);
|
||||
}
|
||||
head.append(controls);
|
||||
}
|
||||
}
|
||||
const body = document.createElement('div'); {
|
||||
buttonHolder = body;
|
||||
body.classList.add('qr--body');
|
||||
if (this.settings.isCombined) {
|
||||
const buttons = document.createElement('div'); {
|
||||
buttonHolder = buttons;
|
||||
buttons.classList.add('qr--buttons');
|
||||
body.append(buttons);
|
||||
}
|
||||
}
|
||||
[...this.settings.config.setList, ...(this.settings.chatConfig?.setList ?? []), ...(this.settings.charConfig?.setList ?? [])]
|
||||
.filter(link=>link.isVisible)
|
||||
.forEach(link=>buttonHolder.append(link.set.render()))
|
||||
;
|
||||
root.append(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.popoutDom;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import { Popup } from '../../../../popup.js';
|
||||
import { getSortableDelay } from '../../../../utils.js';
|
||||
import { log, warn } from '../../index.js';
|
||||
import { QuickReply } from '../QuickReply.js';
|
||||
import { QuickReplySet } from '../QuickReplySet.js';
|
||||
import { QuickReplySettings } from '../QuickReplySettings.js';
|
||||
|
||||
export class SettingsUi {
|
||||
/** @type {QuickReplySettings} */ settings;
|
||||
|
||||
/** @type {HTMLElement} */ template;
|
||||
/** @type {HTMLElement} */ dom;
|
||||
|
||||
/**@type {HTMLInputElement}*/ isEnabled;
|
||||
/**@type {HTMLInputElement}*/ isCombined;
|
||||
/**@type {HTMLInputElement}*/ showPopoutButton;
|
||||
|
||||
/**@type {HTMLElement}*/ globalSetList;
|
||||
|
||||
/**@type {HTMLElement}*/ chatSetList;
|
||||
/**@type {HTMLElement}*/ characterSetList;
|
||||
|
||||
/**@type {QuickReplySet}*/ currentQrSet;
|
||||
/**@type {HTMLInputElement}*/ disableSend;
|
||||
/**@type {HTMLInputElement}*/ placeBeforeInput;
|
||||
/**@type {HTMLInputElement}*/ injectInput;
|
||||
/**@type {HTMLInputElement}*/ color;
|
||||
/**@type {HTMLInputElement}*/ onlyBorderColor;
|
||||
/**@type {HTMLSelectElement}*/ currentSet;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {QuickReplySettings}*/settings) {
|
||||
this.settings = settings;
|
||||
settings.onRequestEditSet = (qrs) => this.selectQrSet(qrs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
rerender() {
|
||||
if (!this.dom) return;
|
||||
const content = this.dom.querySelector('.inline-drawer-content');
|
||||
content.innerHTML = '';
|
||||
// @ts-ignore
|
||||
Array.from(this.template.querySelector('.inline-drawer-content').cloneNode(true).children).forEach(el=>{
|
||||
content.append(el);
|
||||
});
|
||||
this.prepareDom();
|
||||
}
|
||||
unrender() {
|
||||
this.dom?.remove();
|
||||
this.dom = null;
|
||||
}
|
||||
async render() {
|
||||
if (!this.dom) {
|
||||
const response = await fetch('/scripts/extensions/quick-reply/html/settings.html', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
this.template = document.createRange().createContextualFragment(await response.text()).querySelector('#qr--settings');
|
||||
// @ts-ignore
|
||||
this.dom = this.template.cloneNode(true);
|
||||
this.prepareDom();
|
||||
} else {
|
||||
warn('failed to fetch settings template');
|
||||
}
|
||||
}
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
|
||||
prepareGeneralSettings() {
|
||||
// general settings
|
||||
this.isEnabled = this.dom.querySelector('#qr--isEnabled');
|
||||
this.isEnabled.checked = this.settings.isEnabled;
|
||||
this.isEnabled.addEventListener('click', ()=>this.onIsEnabled());
|
||||
|
||||
this.isCombined = this.dom.querySelector('#qr--isCombined');
|
||||
this.isCombined.checked = this.settings.isCombined;
|
||||
this.isCombined.addEventListener('click', ()=>this.onIsCombined());
|
||||
|
||||
this.showPopoutButton = this.dom.querySelector('#qr--showPopoutButton');
|
||||
this.showPopoutButton.checked = this.settings.showPopoutButton;
|
||||
this.showPopoutButton.addEventListener('click', ()=>this.onShowPopoutButton());
|
||||
}
|
||||
|
||||
prepareGlobalSetList() {
|
||||
const dom = this.template.querySelector('#qr--global');
|
||||
const clone = dom.cloneNode(true);
|
||||
// @ts-ignore
|
||||
this.settings.config.renderSettingsInto(clone);
|
||||
this.dom.querySelector('#qr--global').replaceWith(clone);
|
||||
}
|
||||
prepareChatSetList() {
|
||||
const dom = this.template.querySelector('#qr--chat');
|
||||
const clone = dom.cloneNode(true);
|
||||
if (this.settings.chatConfig) {
|
||||
// @ts-ignore
|
||||
this.settings.chatConfig.renderSettingsInto(clone);
|
||||
} else {
|
||||
const info = document.createElement('div'); {
|
||||
info.textContent = 'No active chat.';
|
||||
// @ts-ignore
|
||||
clone.append(info);
|
||||
}
|
||||
}
|
||||
this.dom.querySelector('#qr--chat').replaceWith(clone);
|
||||
}
|
||||
prepareCharacterSetList() {
|
||||
const dom = this.template.querySelector('#qr--character');
|
||||
const clone = /** @type {HTMLElement} */ (dom.cloneNode(true));
|
||||
|
||||
if (!this.settings.charConfig) {
|
||||
const setListContainer = /** @type {HTMLElement} */ (clone.querySelector('.qr--setList'));
|
||||
setListContainer.innerHTML = '';
|
||||
const info = document.createElement('div');
|
||||
info.textContent = 'No character is currently loaded.';
|
||||
setListContainer.append(info);
|
||||
} else {
|
||||
// Let the config object handle its own rendering. It will render an empty list if there are no sets,
|
||||
// but the "add" button will always be functional.
|
||||
this.settings.charConfig.renderSettingsInto(clone);
|
||||
}
|
||||
|
||||
// Replace the old DOM element with our newly prepared clone.
|
||||
this.dom.querySelector('#qr--character').replaceWith(clone);
|
||||
}
|
||||
|
||||
prepareQrEditor() {
|
||||
// qr editor
|
||||
this.dom.querySelector('#qr--set-rename').addEventListener('click', async () => this.renameQrSet());
|
||||
this.dom.querySelector('#qr--set-new').addEventListener('click', async()=>this.addQrSet());
|
||||
/**@type {HTMLInputElement}*/
|
||||
const importFile = this.dom.querySelector('#qr--set-importFile');
|
||||
importFile.addEventListener('change', async()=>{
|
||||
await this.importQrSet(importFile.files);
|
||||
importFile.value = null;
|
||||
});
|
||||
this.dom.querySelector('#qr--set-import').addEventListener('click', ()=>importFile.click());
|
||||
this.dom.querySelector('#qr--set-export').addEventListener('click', async () => this.exportQrSet());
|
||||
this.dom.querySelector('#qr--set-duplicate').addEventListener('click', async () => this.duplicateQrSet());
|
||||
this.dom.querySelector('#qr--set-delete').addEventListener('click', async()=>this.deleteQrSet());
|
||||
this.dom.querySelector('#qr--set-add').addEventListener('click', async()=>{
|
||||
this.currentQrSet.addQuickReply();
|
||||
});
|
||||
this.dom.querySelector('#qr--set-paste').addEventListener('click', async()=>{
|
||||
const text = await navigator.clipboard.readText();
|
||||
this.currentQrSet.addQuickReplyFromText(text);
|
||||
});
|
||||
this.dom.querySelector('#qr--set-importQr').addEventListener('click', async()=>{
|
||||
const inp = document.createElement('input'); {
|
||||
inp.type = 'file';
|
||||
inp.accept = '.json';
|
||||
inp.addEventListener('change', async()=>{
|
||||
if (inp.files.length > 0) {
|
||||
for (const file of inp.files) {
|
||||
const text = await file.text();
|
||||
this.currentQrSet.addQuickReply(JSON.parse(text));
|
||||
}
|
||||
}
|
||||
});
|
||||
inp.click();
|
||||
}
|
||||
});
|
||||
this.qrList = this.dom.querySelector('#qr--set-qrList');
|
||||
this.currentSet = this.dom.querySelector('#qr--set');
|
||||
this.currentSet.addEventListener('change', ()=>this.onQrSetChange());
|
||||
QuickReplySet.list.toSorted((a,b)=>a.name.toLowerCase().localeCompare(b.name.toLowerCase())).forEach(qrs=>{
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
this.currentSet.append(opt);
|
||||
}
|
||||
});
|
||||
this.disableSend = this.dom.querySelector('#qr--disableSend');
|
||||
this.disableSend.addEventListener('click', ()=>{
|
||||
const qrs = this.currentQrSet;
|
||||
qrs.disableSend = this.disableSend.checked;
|
||||
qrs.save();
|
||||
});
|
||||
this.placeBeforeInput = this.dom.querySelector('#qr--placeBeforeInput');
|
||||
this.placeBeforeInput.addEventListener('click', ()=>{
|
||||
const qrs = this.currentQrSet;
|
||||
qrs.placeBeforeInput = this.placeBeforeInput.checked;
|
||||
qrs.save();
|
||||
});
|
||||
this.injectInput = this.dom.querySelector('#qr--injectInput');
|
||||
this.injectInput.addEventListener('click', ()=>{
|
||||
const qrs = this.currentQrSet;
|
||||
qrs.injectInput = this.injectInput.checked;
|
||||
qrs.save();
|
||||
});
|
||||
let initialColorChange = true;
|
||||
this.color = this.dom.querySelector('#qr--color');
|
||||
// @ts-ignore
|
||||
this.color.color = this.currentQrSet?.color ?? 'transparent';
|
||||
this.color.addEventListener('change', (evt)=>{
|
||||
if (!this.dom.closest('body')) return;
|
||||
const qrs = this.currentQrSet;
|
||||
if (initialColorChange) {
|
||||
initialColorChange = false;
|
||||
// @ts-ignore
|
||||
this.color.color = qrs.color;
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
qrs.color = evt.detail.rgb;
|
||||
qrs.save();
|
||||
this.currentQrSet.updateColor();
|
||||
});
|
||||
// @ts-ignore
|
||||
this.dom.querySelector('#qr--colorClear').addEventListener('click', (evt)=>{
|
||||
const qrs = this.currentQrSet;
|
||||
// @ts-ignore
|
||||
this.color.color = 'transparent';
|
||||
qrs.save();
|
||||
this.currentQrSet.updateColor();
|
||||
});
|
||||
this.onlyBorderColor = this.dom.querySelector('#qr--onlyBorderColor');
|
||||
this.onlyBorderColor.addEventListener('click', ()=>{
|
||||
const qrs = this.currentQrSet;
|
||||
qrs.onlyBorderColor = this.onlyBorderColor.checked;
|
||||
qrs.save();
|
||||
this.currentQrSet.updateColor();
|
||||
});
|
||||
this.onQrSetChange();
|
||||
}
|
||||
onQrSetChange() {
|
||||
this.currentQrSet = QuickReplySet.get(this.currentSet.value) ?? new QuickReplySet();
|
||||
this.disableSend.checked = this.currentQrSet.disableSend;
|
||||
this.placeBeforeInput.checked = this.currentQrSet.placeBeforeInput;
|
||||
this.injectInput.checked = this.currentQrSet.injectInput;
|
||||
// @ts-ignore
|
||||
this.color.color = this.currentQrSet.color ?? 'transparent';
|
||||
this.onlyBorderColor.checked = this.currentQrSet.onlyBorderColor;
|
||||
this.qrList.innerHTML = '';
|
||||
const qrsDom = this.currentQrSet.renderSettings();
|
||||
this.qrList.append(qrsDom);
|
||||
// @ts-ignore
|
||||
$(qrsDom).sortable({
|
||||
delay: getSortableDelay(),
|
||||
handle: '.drag-handle',
|
||||
stop: ()=>this.onQrListSort(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
prepareDom() {
|
||||
this.prepareGeneralSettings();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
this.prepareCharacterSetList();
|
||||
this.prepareQrEditor();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async onIsEnabled() {
|
||||
this.settings.isEnabled = this.isEnabled.checked;
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async onIsCombined() {
|
||||
this.settings.isCombined = this.isCombined.checked;
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async onShowPopoutButton() {
|
||||
this.settings.showPopoutButton = this.showPopoutButton.checked;
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async onGlobalSetListSort() {
|
||||
this.settings.config.setList = Array.from(this.globalSetList.children).map((it,idx)=>{
|
||||
const set = this.settings.config.setList[Number(it.getAttribute('data-order'))];
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return set;
|
||||
});
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async onChatSetListSort() {
|
||||
this.settings.chatConfig.setList = Array.from(this.chatSetList.children).map((it,idx)=>{
|
||||
const set = this.settings.chatConfig.setList[Number(it.getAttribute('data-order'))];
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return set;
|
||||
});
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
updateOrder(list) {
|
||||
Array.from(list.children).forEach((it,idx)=>{
|
||||
it.setAttribute('data-order', idx);
|
||||
});
|
||||
}
|
||||
|
||||
async onQrListSort() {
|
||||
this.currentQrSet.qrList = Array.from(this.qrList.querySelectorAll('.qr--set-item')).map((it,idx)=>{
|
||||
const qr = this.currentQrSet.qrList.find(qr=>qr.id == Number(it.getAttribute('data-id')));
|
||||
it.setAttribute('data-order', String(idx));
|
||||
return qr;
|
||||
});
|
||||
this.currentQrSet.save();
|
||||
}
|
||||
|
||||
async deleteQrSet() {
|
||||
const confirmed = await Popup.show.confirm('Delete Quick Reply Set', `Are you sure you want to delete the Quick Reply Set "${this.currentQrSet.name}"?<br>This cannot be undone.`);
|
||||
if (confirmed) {
|
||||
await this.doDeleteQrSet(this.currentQrSet);
|
||||
this.rerender();
|
||||
}
|
||||
}
|
||||
async doDeleteQrSet(qrs) {
|
||||
await qrs.delete();
|
||||
//TODO (HACK) should just bubble up from QuickReplySet.delete() but that would require proper or at least more comples onDelete listeners
|
||||
for (let i = this.settings.config.setList.length - 1; i >= 0; i--) {
|
||||
if (this.settings.config.setList[i].set == qrs) {
|
||||
this.settings.config.setList.splice(i, 1);
|
||||
}
|
||||
}
|
||||
if (this.settings.chatConfig) {
|
||||
for (let i = this.settings.chatConfig.setList.length - 1; i >= 0; i--) {
|
||||
if (this.settings.chatConfig.setList[i].set == qrs) {
|
||||
this.settings.chatConfig.setList.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.settings.charConfig) {
|
||||
for (let i = this.settings.charConfig.setList.length - 1; i >= 0; i--) {
|
||||
if (this.settings.charConfig.setList[i].set == qrs) {
|
||||
this.settings.charConfig.setList.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.settings.save();
|
||||
}
|
||||
|
||||
async renameQrSet() {
|
||||
const newName = await Popup.show.input('Rename Quick Reply Set', 'Enter a new name:', this.currentQrSet.name);
|
||||
if (newName && newName.length > 0) {
|
||||
const existingSet = QuickReplySet.get(newName);
|
||||
if (existingSet) {
|
||||
toastr.error(`A Quick Reply Set named "${newName}" already exists.`);
|
||||
return;
|
||||
}
|
||||
const oldName = this.currentQrSet.name;
|
||||
this.currentQrSet.name = newName;
|
||||
await this.currentQrSet.save();
|
||||
|
||||
// Update it in both set lists
|
||||
this.settings.config.setList.forEach(set => {
|
||||
if (set.set.name === oldName) {
|
||||
set.set.name = newName;
|
||||
}
|
||||
});
|
||||
this.settings.chatConfig?.setList.forEach(set => {
|
||||
if (set.set.name === oldName) {
|
||||
set.set.name = newName;
|
||||
}
|
||||
});
|
||||
this.settings.charConfig?.setList.forEach(set => {
|
||||
if (set.set.name === oldName) {
|
||||
set.set.name = newName;
|
||||
}
|
||||
});
|
||||
this.settings.save();
|
||||
|
||||
// Update the option in the current selected QR dropdown. All others will be refreshed via the prepare calls below.
|
||||
/** @type {HTMLOptionElement} */
|
||||
const option = this.currentSet.querySelector(`#qr--set option[value="${oldName}"]`);
|
||||
option.value = newName;
|
||||
option.textContent = newName;
|
||||
|
||||
this.currentSet.value = newName;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
this.prepareCharacterSetList();
|
||||
|
||||
console.info(`Quick Reply Set renamed from ""${oldName}" to "${newName}".`);
|
||||
}
|
||||
}
|
||||
|
||||
async addQrSet() {
|
||||
const name = await Popup.show.input('Create a new Quick Reply Set', 'Enter a name for the new Quick Reply Set:');
|
||||
if (name && name.length > 0) {
|
||||
const oldQrs = QuickReplySet.get(name);
|
||||
if (oldQrs) {
|
||||
const replace = Popup.show.confirm('Replace existing World Info', `A Quick Reply Set named "${name}" already exists.<br>Do you want to overwrite the existing Quick Reply Set?<br>The existing set will be deleted. This cannot be undone.`);
|
||||
if (replace) {
|
||||
const idx = QuickReplySet.list.indexOf(oldQrs);
|
||||
await this.doDeleteQrSet(oldQrs);
|
||||
const qrs = new QuickReplySet();
|
||||
qrs.name = name;
|
||||
qrs.addQuickReply();
|
||||
QuickReplySet.list.splice(idx, 0, qrs);
|
||||
this.rerender();
|
||||
this.currentSet.value = name;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
this.prepareCharacterSetList();
|
||||
}
|
||||
} else {
|
||||
const qrs = new QuickReplySet();
|
||||
qrs.name = name;
|
||||
qrs.addQuickReply();
|
||||
const idx = QuickReplySet.list.findIndex(it=>it.name.toLowerCase().localeCompare(name.toLowerCase()) == 1);
|
||||
if (idx > -1) {
|
||||
QuickReplySet.list.splice(idx, 0, qrs);
|
||||
} else {
|
||||
QuickReplySet.list.push(qrs);
|
||||
}
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
if (idx > -1) {
|
||||
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
|
||||
} else {
|
||||
this.currentSet.append(opt);
|
||||
}
|
||||
}
|
||||
this.currentSet.value = name;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
this.prepareCharacterSetList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async importQrSet(/**@type {FileList}*/files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await this.importSingleQrSet(files.item(i));
|
||||
}
|
||||
}
|
||||
async importSingleQrSet(/**@type {File}*/file) {
|
||||
log('FILE', file);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const props = JSON.parse(text);
|
||||
if (!Number.isInteger(props.version) || typeof props.name != 'string') {
|
||||
toastr.error(`The file "${file.name}" does not appear to be a valid quick reply set.`);
|
||||
warn(`The file "${file.name}" does not appear to be a valid quick reply set.`);
|
||||
} else {
|
||||
/**@type {QuickReplySet}*/
|
||||
const qrs = QuickReplySet.from(JSON.parse(JSON.stringify(props)));
|
||||
qrs.qrList = props.qrList.map(it=>QuickReply.from(it));
|
||||
qrs.init();
|
||||
const oldQrs = QuickReplySet.get(props.name);
|
||||
if (oldQrs) {
|
||||
const replace = Popup.show.confirm('Replace existing World Info', `A Quick Reply Set named "${name}" already exists.<br>Do you want to overwrite the existing Quick Reply Set?<br>The existing set will be deleted. This cannot be undone.`);
|
||||
if (replace) {
|
||||
const idx = QuickReplySet.list.indexOf(oldQrs);
|
||||
await this.doDeleteQrSet(oldQrs);
|
||||
QuickReplySet.list.splice(idx, 0, qrs);
|
||||
await qrs.save();
|
||||
this.rerender();
|
||||
this.currentSet.value = qrs.name;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
this.prepareCharacterSetList();
|
||||
}
|
||||
} else {
|
||||
const idx = QuickReplySet.list.findIndex(it=>it.name.toLowerCase().localeCompare(qrs.name.toLowerCase()) == 1);
|
||||
if (idx > -1) {
|
||||
QuickReplySet.list.splice(idx, 0, qrs);
|
||||
} else {
|
||||
QuickReplySet.list.push(qrs);
|
||||
}
|
||||
await qrs.save();
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = qrs.name;
|
||||
opt.textContent = qrs.name;
|
||||
if (idx > -1) {
|
||||
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
|
||||
} else {
|
||||
this.currentSet.append(opt);
|
||||
}
|
||||
}
|
||||
this.currentSet.value = qrs.name;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
this.prepareCharacterSetList();
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
warn(ex);
|
||||
toastr.error(`Failed to import "${file.name}":\n\n${ex.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
exportQrSet() {
|
||||
const blob = new Blob([JSON.stringify(this.currentQrSet)], { type:'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); {
|
||||
a.href = url;
|
||||
a.download = `${this.currentQrSet.name}.json`;
|
||||
a.click();
|
||||
}
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async duplicateQrSet() {
|
||||
const newName = await Popup.show.input('Duplicate Quick Reply Set', 'Enter a name for the new Quick Reply Set:', `${this.currentQrSet.name} (Copy)`);
|
||||
if (newName && newName.length > 0) {
|
||||
const existingSet = QuickReplySet.get(newName);
|
||||
if (existingSet) {
|
||||
toastr.error(`A Quick Reply Set named "${newName}" already exists.`);
|
||||
return;
|
||||
}
|
||||
const newQrSet = QuickReplySet.from(this.currentQrSet.toJSON());
|
||||
newQrSet.name = newName;
|
||||
newQrSet.qrList = this.currentQrSet.qrList.map(qr => QuickReply.from(qr.toJSON()));
|
||||
newQrSet.init();
|
||||
const idx = QuickReplySet.list.findIndex(it => it.name.toLowerCase().localeCompare(newName.toLowerCase()) == 1);
|
||||
if (idx > -1) {
|
||||
QuickReplySet.list.splice(idx, 0, newQrSet);
|
||||
} else {
|
||||
QuickReplySet.list.push(newQrSet);
|
||||
}
|
||||
const opt = document.createElement('option'); {
|
||||
opt.value = newQrSet.name;
|
||||
opt.textContent = newQrSet.name;
|
||||
if (idx > -1) {
|
||||
this.currentSet.children[idx].insertAdjacentElement('beforebegin', opt);
|
||||
} else {
|
||||
this.currentSet.append(opt);
|
||||
}
|
||||
}
|
||||
this.currentSet.value = newName;
|
||||
this.onQrSetChange();
|
||||
this.prepareGlobalSetList();
|
||||
this.prepareChatSetList();
|
||||
this.prepareCharacterSetList();
|
||||
}
|
||||
}
|
||||
|
||||
selectQrSet(qrs) {
|
||||
this.currentSet.value = qrs.name;
|
||||
this.onQrSetChange();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { QuickReply } from '../../QuickReply.js';
|
||||
import { QuickReplySet } from '../../QuickReplySet.js';
|
||||
import { MenuHeader } from './MenuHeader.js';
|
||||
import { MenuItem } from './MenuItem.js';
|
||||
|
||||
export class ContextMenu {
|
||||
/**@type {MenuItem[]}*/ itemList = [];
|
||||
/**@type {Boolean}*/ isActive = false;
|
||||
|
||||
/**@type {HTMLElement}*/ root;
|
||||
/**@type {HTMLElement}*/ menu;
|
||||
|
||||
|
||||
|
||||
|
||||
constructor(/**@type {QuickReply}*/qr) {
|
||||
// this.itemList = items;
|
||||
this.itemList = this.build(qr).children;
|
||||
this.itemList.forEach(item => {
|
||||
item.onExpand = () => {
|
||||
this.itemList.filter(it => it !== item)
|
||||
.forEach(it => it.collapse());
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QuickReply} qr
|
||||
* @param {String} chainedMessage
|
||||
* @param {QuickReplySet[]} hierarchy
|
||||
* @param {String[]} labelHierarchy
|
||||
*/
|
||||
build(qr, chainedMessage = null, hierarchy = [], labelHierarchy = []) {
|
||||
const tree = {
|
||||
icon: qr.icon,
|
||||
showLabel: qr.showLabel,
|
||||
label: qr.label,
|
||||
title: qr.title,
|
||||
message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
|
||||
children: [],
|
||||
};
|
||||
qr.contextList.forEach((cl) => {
|
||||
if (!cl.set) return;
|
||||
if (!hierarchy.includes(cl.set)) {
|
||||
const nextHierarchy = [...hierarchy, cl.set];
|
||||
const nextLabelHierarchy = [...labelHierarchy, tree.label];
|
||||
tree.children.push(new MenuHeader(cl.set.name));
|
||||
|
||||
// If the Quick Reply's own set is added as a context menu,
|
||||
// show only the sub-QRs that are Invisible but have an icon
|
||||
// intent: allow a QR set to be assigned to one of its own QR buttons for a "burger" menu
|
||||
// with "UI" QRs either in the bar or in the menu, and "library function" QRs still hidden.
|
||||
// - QRs already visible on the bar are filtered out,
|
||||
// - hidden QRs without an icon are filtered out,
|
||||
// - hidden QRs **with an icon** are shown in the menu
|
||||
// so everybody is happy
|
||||
const qrsOwnSetAddedAsContextMenu = cl.set.qrList.includes(qr);
|
||||
const visible = (subQr) => {
|
||||
return qrsOwnSetAddedAsContextMenu
|
||||
? subQr.isHidden && !!subQr.icon // yes .isHidden gets inverted here
|
||||
: !subQr.isHidden;
|
||||
};
|
||||
|
||||
cl.set.qrList.filter(visible).forEach(subQr => {
|
||||
const subTree = this.build(subQr, cl.isChained ? tree.message : null, nextHierarchy, nextLabelHierarchy);
|
||||
tree.children.push(new MenuItem(
|
||||
subTree.icon,
|
||||
subTree.showLabel,
|
||||
subTree.label,
|
||||
subTree.title,
|
||||
subTree.message,
|
||||
(evt) => {
|
||||
evt.stopPropagation();
|
||||
const finalQr = Object.assign(new QuickReply(), subQr);
|
||||
finalQr.message = subTree.message.replace(/%%parent(-\d+)?%%/g, (_, index) => {
|
||||
return nextLabelHierarchy.slice(parseInt(index ?? '-1'))[0];
|
||||
});
|
||||
cl.set.execute(finalQr);
|
||||
},
|
||||
subTree.children,
|
||||
));
|
||||
});
|
||||
}
|
||||
});
|
||||
return tree;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.root) {
|
||||
const blocker = document.createElement('div'); {
|
||||
this.root = blocker;
|
||||
blocker.classList.add('ctx-blocker');
|
||||
blocker.addEventListener('click', () => this.hide());
|
||||
const menu = document.createElement('ul'); {
|
||||
this.menu = menu;
|
||||
menu.classList.add('list-group');
|
||||
menu.classList.add('ctx-menu');
|
||||
this.itemList.forEach(it => menu.append(it.render()));
|
||||
blocker.append(menu);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.root;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
show({ clientX, clientY }) {
|
||||
if (this.isActive) return;
|
||||
this.isActive = true;
|
||||
this.render();
|
||||
this.menu.style.bottom = `${window.innerHeight - clientY}px`;
|
||||
this.menu.style.left = `${clientX}px`;
|
||||
document.body.append(this.root);
|
||||
}
|
||||
hide() {
|
||||
if (this.root) {
|
||||
this.root.remove();
|
||||
}
|
||||
this.isActive = false;
|
||||
}
|
||||
toggle(/**@type {PointerEvent}*/evt) {
|
||||
if (this.isActive) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MenuItem } from './MenuItem.js';
|
||||
|
||||
export class MenuHeader extends MenuItem {
|
||||
constructor(/**@type {String}*/label) {
|
||||
super(null, null, label, null, null, null, []);
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
if (!this.root) {
|
||||
const item = document.createElement('li'); {
|
||||
this.root = item;
|
||||
item.classList.add('list-group-item');
|
||||
item.classList.add('ctx-header');
|
||||
item.append(this.label);
|
||||
}
|
||||
}
|
||||
return this.root;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { SubMenu } from './SubMenu.js';
|
||||
|
||||
export class MenuItem {
|
||||
/**@type {string}*/ icon;
|
||||
/**@type {boolean}*/ showLabel;
|
||||
/**@type {string}*/ label;
|
||||
/**@type {string}*/ title;
|
||||
/**@type {object}*/ value;
|
||||
/**@type {function}*/ callback;
|
||||
/**@type {MenuItem[]}*/ childList = [];
|
||||
/**@type {SubMenu}*/ subMenu;
|
||||
|
||||
/**@type {HTMLElement}*/ root;
|
||||
|
||||
/**@type {function}*/ onExpand;
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {?string} icon
|
||||
* @param {?boolean} showLabel
|
||||
* @param {string} label
|
||||
* @param {?string} title Tooltip
|
||||
* @param {object} value
|
||||
* @param {function} callback
|
||||
* @param {MenuItem[]} children
|
||||
*/
|
||||
constructor(icon, showLabel, label, title, value, callback, children = []) {
|
||||
this.icon = icon;
|
||||
this.showLabel = showLabel;
|
||||
this.label = label;
|
||||
this.title = title;
|
||||
this.value = value;
|
||||
this.callback = callback;
|
||||
this.childList = children;
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
if (!this.root) {
|
||||
const item = document.createElement('li'); {
|
||||
this.root = item;
|
||||
item.classList.add('list-group-item');
|
||||
item.classList.add('ctx-item');
|
||||
|
||||
// if a title/tooltip is set, add it, otherwise use the QR content
|
||||
// same as for the main QR list
|
||||
item.title = this.title || this.value;
|
||||
|
||||
if (this.callback) {
|
||||
item.addEventListener('click', (evt) => this.callback(evt, this));
|
||||
}
|
||||
const icon = document.createElement('div'); {
|
||||
icon.classList.add('qr--button-icon');
|
||||
icon.classList.add('fa-solid');
|
||||
if (!this.icon) icon.classList.add('qr--hidden');
|
||||
else icon.classList.add(this.icon);
|
||||
item.append(icon);
|
||||
}
|
||||
const lbl = document.createElement('div'); {
|
||||
lbl.classList.add('qr--button-label');
|
||||
if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');
|
||||
lbl.textContent = this.label;
|
||||
item.append(lbl);
|
||||
}
|
||||
if (this.childList.length > 0) {
|
||||
item.classList.add('ctx-has-children');
|
||||
const sub = new SubMenu(this.childList);
|
||||
this.subMenu = sub;
|
||||
const trigger = document.createElement('div'); {
|
||||
trigger.classList.add('ctx-expander');
|
||||
trigger.textContent = '⋮';
|
||||
trigger.addEventListener('click', (evt) => {
|
||||
evt.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
item.append(trigger);
|
||||
}
|
||||
item.addEventListener('mouseover', () => sub.show(item));
|
||||
item.addEventListener('mouseleave', () => sub.hide());
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.root;
|
||||
}
|
||||
|
||||
|
||||
expand() {
|
||||
this.subMenu?.show(this.root);
|
||||
if (this.onExpand) {
|
||||
this.onExpand();
|
||||
}
|
||||
}
|
||||
collapse() {
|
||||
this.subMenu?.hide();
|
||||
}
|
||||
toggle() {
|
||||
if (this.subMenu.isActive) {
|
||||
this.expand();
|
||||
} else {
|
||||
this.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user