This commit is contained in:
2025-09-14 23:43:10 +08:00
commit abdbd63d63
1222 changed files with 720115 additions and 0 deletions

View File

@@ -0,0 +1,884 @@
'use strict';
import {
characterGroupOverlay,
callPopup,
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 { 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) => 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);
}
}
$('#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 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;
CharacterContextMenu.show(...this.#getContextMenuPosition(event));
}
}
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 = 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();
CharacterContextMenu.show(...this.#getContextMenuPosition(event));
this.#contextMenuOpen = true;
};
handleContextMenuHide = (event) => {
let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
if (false === contextMenu.contains(event.target)) {
CharacterContextMenu.hide();
}
};
/**
* 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 promise = callPopup(popupContent, null)
.then((accept) => {
if (true !== accept) return;
const deleteChats = document.getElementById('del_char_checkbox').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 };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,584 @@
import {
animation_duration,
chat_metadata,
eventSource,
event_types,
extension_prompt_roles,
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 { MacrosParser } from './macros.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_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,
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
if ($('#floatingPrompt').css('display') !== 'flex') {
$('#floatingPrompt').addClass('resizing');
$('#floatingPrompt').css('display', 'flex');
$('#floatingPrompt').css('opacity', 0.0);
$('#floatingPrompt').transition({
opacity: 1.0,
duration: animation_duration,
}, async function () {
await delay(50);
$('#floatingPrompt').removeClass('resizing');
});
//auto-open the main AN inline drawer
if ($('#ANBlockToggle')
.siblings('.inline-drawer-content')
.css('display') !== 'block') {
$('#floatingPrompt').addClass('resizing');
$('#ANBlockToggle').click();
}
} else {
//hide AN if it's already displayed
$('#floatingPrompt').addClass('resizing');
$('#floatingPrompt').transition({
opacity: 0.0,
duration: animation_duration,
}, async function () {
await delay(50);
$('#floatingPrompt').removeClass('resizing');
});
setTimeout(function () {
$('#floatingPrompt').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);
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`);
}

View File

@@ -0,0 +1,822 @@
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
};
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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,6 @@
import { AutoCompleteNameResultBase } from './AutoCompleteNameResultBase.js';
export class AutoCompleteSecondaryNameResult extends AutoCompleteNameResultBase {
/**@type {boolean}*/ isRequired = false;
/**@type {boolean}*/ forceMatch = true;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,547 @@
import { Fuse } from '../lib.js';
import { callPopup, chat_metadata, eventSource, event_types, generateQuietPrompt, getCurrentChatId, getRequestHeaders, getThumbnailUrl, saveSettingsDebounced } from '../script.js';
import { saveMetadataDebounced } from './extensions.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { flashHighlight, stringFormat } from './utils.js';
import { t } from './i18n.js';
const BG_METADATA_KEY = 'custom_background';
const LIST_METADATA_KEY = 'chat_backgrounds';
export let background_settings = {
name: '__transparent.png',
url: generateUrlParameter('__transparent.png', false),
fitting: 'classic',
};
export function loadBackgroundSettings(settings) {
let backgroundSettings = settings.background;
if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {
backgroundSettings = background_settings;
}
if (!backgroundSettings.fitting) {
backgroundSettings.fitting = 'classic';
}
setBackground(backgroundSettings.name, backgroundSettings.url);
setFittingClass(backgroundSettings.fitting);
$('#background_fitting').val(backgroundSettings.fitting);
}
/**
* Sets the background for the current chat and adds it to the list of custom backgrounds.
* @param {{url: string, path:string}} backgroundInfo
*/
function forceSetBackground(backgroundInfo) {
saveBackgroundMetadata(backgroundInfo.url);
setCustomBackground();
const list = chat_metadata[LIST_METADATA_KEY] || [];
const bg = backgroundInfo.path;
list.push(bg);
chat_metadata[LIST_METADATA_KEY] = list;
saveMetadataDebounced();
getChatBackgroundsList();
highlightNewBackground(bg);
highlightLockedBackground();
}
async function onChatChanged() {
if (hasCustomBackground()) {
setCustomBackground();
}
else {
unsetCustomBackground();
}
getChatBackgroundsList();
highlightLockedBackground();
}
function getChatBackgroundsList() {
const list = chat_metadata[LIST_METADATA_KEY];
const listEmpty = !Array.isArray(list) || list.length === 0;
$('#bg_custom_content').empty();
$('#bg_chat_hint').toggle(listEmpty);
if (listEmpty) {
return;
}
for (const bg of list) {
const template = getBackgroundFromTemplate(bg, true);
$('#bg_custom_content').append(template);
}
}
function getBackgroundPath(fileUrl) {
return `backgrounds/${fileUrl}`;
}
function highlightLockedBackground() {
$('.bg_example').removeClass('locked');
const lockedBackground = chat_metadata[BG_METADATA_KEY];
if (!lockedBackground) {
return;
}
$('.bg_example').each(function () {
const url = $(this).data('url');
if (url === lockedBackground) {
$(this).addClass('locked');
}
});
}
/**
* Locks the background for the current chat
* @param {Event} e Click event
* @returns {string} Empty string
*/
function onLockBackgroundClick(e) {
e?.stopPropagation();
const chatName = getCurrentChatId();
if (!chatName) {
toastr.warning('Select a chat to lock the background for it');
return '';
}
const relativeBgImage = getUrlParameter(this) ?? background_settings.url;
saveBackgroundMetadata(relativeBgImage);
setCustomBackground();
highlightLockedBackground();
return '';
}
/**
* Locks the background for the current chat
* @param {Event} e Click event
* @returns {string} Empty string
*/
function onUnlockBackgroundClick(e) {
e?.stopPropagation();
removeBackgroundMetadata();
unsetCustomBackground();
highlightLockedBackground();
return '';
}
function hasCustomBackground() {
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();
}
function setCustomBackground() {
const file = chat_metadata[BG_METADATA_KEY];
// bg already set
if (document.getElementById('bg_custom').style.backgroundImage == file) {
return;
}
$('#bg_custom').css('background-image', file);
}
function unsetCustomBackground() {
$('#bg_custom').css('background-image', 'none');
}
function onSelectBackgroundClick() {
const isCustom = $(this).attr('custom') === 'true';
const relativeBgImage = getUrlParameter(this);
// if clicked on upload button
if (!relativeBgImage) {
return;
}
// Automatically lock the background if it's custom or other background is locked
if (hasCustomBackground() || isCustom) {
saveBackgroundMetadata(relativeBgImage);
setCustomBackground();
highlightLockedBackground();
}
highlightLockedBackground();
const customBg = window.getComputedStyle(document.getElementById('bg_custom')).backgroundImage;
// Custom background is set. Do not override the layer below
if (customBg !== 'none') {
return;
}
const bgFile = $(this).attr('bgfile');
const backgroundUrl = getBackgroundPath(bgFile);
// Fetching to browser memory to reduce flicker
fetch(backgroundUrl).then(() => {
setBackground(bgFile, relativeBgImage);
}).catch(() => {
console.log('Background could not be set: ' + backgroundUrl);
});
}
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);
uploadBackground(formData);
const list = chat_metadata[LIST_METADATA_KEY] || [];
const index = list.indexOf(bgNames.oldBg);
list.splice(index, 1);
saveMetadataDebounced();
getChatBackgroundsList();
}
/**
* 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 callPopup('<h3>' + t`Enter new background name:` + '</h3>', 'input', 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 confirm = await callPopup('<h3>Delete the background?</h3>', 'confirm');
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) {
delBackground(bg);
} else {
const list = chat_metadata[LIST_METADATA_KEY] || [];
const index = list.indexOf(bg);
list.splice(index, 1);
}
const siblingSelector = '.bg_example:not(#form_bg_download)';
const nextBg = bgToDelete.next(siblingSelector);
const prevBg = bgToDelete.prev(siblingSelector);
const anyBg = $(siblingSelector);
if (nextBg.length > 0) {
nextBg.trigger('click');
} else if (prevBg.length > 0) {
prevBg.trigger('click');
} else {
$(anyBg[Math.floor(Math.random() * anyBg.length)]).trigger('click');
}
bgToDelete.remove();
if (url === chat_metadata[BG_METADATA_KEY]) {
removeBackgroundMetadata();
unsetCustomBackground();
highlightLockedBackground();
}
if (isCustom) {
getChatBackgroundsList();
saveMetadataDebounced();
}
}
}
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(prompt, false, false);
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 '';
}
export async function getBackgrounds() {
const response = await fetch('/api/backgrounds/all', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
'': '',
}),
});
if (response.ok) {
const getData = await response.json();
//background = getData;
//console.log(getData.length);
$('#bg_menu_content').children('div').remove();
for (const bg of getData) {
const template = getBackgroundFromTemplate(bg, false);
$('#bg_menu_content').append(template);
}
}
}
/**
* 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)}")`;
}
/**
* Instantiates a background template
* @param {string} bg Path to background
* @param {boolean} isCustom Whether the background is custom
* @returns {JQuery<HTMLElement>} Background template
*/
function getBackgroundFromTemplate(bg, isCustom) {
const template = $('#background_template .bg_example').clone();
const thumbPath = isCustom ? bg : getThumbnailUrl('bg', bg);
const url = generateUrlParameter(bg, isCustom);
const title = isCustom ? bg.split('/').pop() : bg;
const friendlyTitle = title.slice(0, title.lastIndexOf('.'));
template.attr('title', title);
template.attr('bgfile', bg);
template.attr('custom', String(isCustom));
template.data('url', url);
template.css('background-image', `url('${thumbPath}')`);
template.find('.BGSampleTitle').text(friendlyTitle);
return template;
}
async function setBackground(bg, url) {
$('#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,
}),
});
}
function onBackgroundUploadSelected() {
const form = $('#form_bg_download').get(0);
if (!(form instanceof HTMLFormElement)) {
console.error('form_bg_download is not a form');
return;
}
const formData = new FormData(form);
uploadBackground(formData);
form.reset();
}
/**
* Uploads a background to the server
* @param {FormData} formData
*/
function uploadBackground(formData) {
jQuery.ajax({
type: 'POST',
url: '/api/backgrounds/upload',
data: formData,
beforeSend: function () {
},
cache: false,
contentType: false,
processData: false,
success: async function (bg) {
setBackground(bg, generateUrlParameter(bg, false));
await getBackgrounds();
highlightNewBackground(bg);
},
error: function (jqXHR, exception) {
console.log(exception);
console.log(jqXHR);
},
});
}
/**
* @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, #bg_custom');
for (const option of ['cover', 'contain', 'stretch', 'center']) {
backgrounds.toggleClass(option, option === fitting);
}
background_settings.fitting = fitting;
}
function onBackgroundFilterInput() {
const filterValue = String($(this).val()).toLowerCase();
$('#bg_menu_content > div').each(function () {
const $bgContent = $(this);
if ($bgContent.attr('title').toLowerCase().includes(filterValue)) {
$bgContent.show();
} else {
$bgContent.hide();
}
});
}
export function initBackgrounds() {
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
$(document).on('click', '.bg_example', onSelectBackgroundClick);
$(document).on('click', '.bg_example_lock', onLockBackgroundClick);
$(document).on('click', '.bg_example_unlock', onUnlockBackgroundClick);
$(document).on('click', '.bg_example_edit', onRenameBackgroundClick);
$(document).on('click', '.bg_example_cross', onDeleteBackgroundClick);
$(document).on('click', '.bg_example_copy', onCopyToSystemBackgroundClick);
$('#auto_background').on('click', autoBackgroundCommand);
$('#add_bg_button').on('change', onBackgroundUploadSelected);
$('#bg-filter').on('input', onBackgroundFilterInput);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'lockbg',
callback: () => onLockBackgroundClick(new CustomEvent('click')),
aliases: ['bglock'],
helpString: 'Locks a background for the currently selected chat',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'unlockbg',
callback: () => onUnlockBackgroundClick(new CustomEvent('click')),
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();
});
}

646
public/scripts/bookmarks.js Normal file
View File

@@ -0,0 +1,646 @@
import {
characters,
saveChat,
system_message_types,
this_chid,
openCharacterChat,
chat_metadata,
getRequestHeaders,
getThumbnailUrl,
getCharacters,
chat,
saveChatConditional,
saveItemizedPrompts,
} from '../script.js';
import { humanizedDateTime } from './RossAscends-mods.js';
import {
getGroupPastChats,
group_activation_strategy,
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 #';
async function getExistingChatNames() {
if (selected_group) {
const data = await getGroupPastChats(selected_group);
return data.map(x => x.file_name);
} else {
const response = await fetch('/api/characters/chats', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ avatar_url: characters[this_chid].avatar }),
});
if (response.ok) {
const data = await response.json();
return Object.values(data).map(x => x.file_name.replace('.jsonl', ''));
}
}
}
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 activationStrategy = group_activation_strategy.NATURAL;
const allowSelfResponses = false;
const favChecked = character.fav || character.fav == 'true';
/** @type {any} */
const metadata = Object.assign({}, chat_metadata);
delete metadata.main_chat;
const createGroupResponse = await fetch('/api/groups/create', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
name: name,
members: members,
avatar_url: avatar,
allow_self_responses: activationStrategy,
activation_strategy: allowSelfResponses,
disabled_members: [],
chat_metadata: metadata,
fav: favChecked,
chat_id: chatName,
chats: chats,
}),
});
if (!createGroupResponse.ok) {
console.error('Group creation unsuccessful');
return;
}
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.slice();
const genIdFirst = Date.now();
for (let index = 0; index < groupChat.length; index++) {
const message = groupChat[index];
// Save group-chat marker
if (index == 0) {
message.is_group = true;
}
// 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;
}
// 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
if (typeof message.extra !== 'object') {
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: 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
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();
}

View 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 };

124
public/scripts/bulk-edit.js Normal file
View File

@@ -0,0 +1,124 @@
import { characterGroupOverlay } from '../script.js';
import { BulkEditOverlay, BulkEditOverlayState } from './BulkEditOverlay.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);
}

498
public/scripts/cfg-scale.js Normal file
View 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').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,
};
}

118
public/scripts/char-data.js Normal file
View File

@@ -0,0 +1,118 @@
/**
* @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.
*/
/**
* @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

View File

@@ -0,0 +1,107 @@
// 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'
,
'3c4ad5fa60dd8c7ccdf82fa4225864c903e107728fcaf859fa6052cb80c92ee9':
// Mistral-Large-Instruct-2411
'Mistral V7' // https://huggingface.co/mistralai/Mistral-Large-Instruct-2411
,
'e4676cb56dffea7782fd3e2b577cfaf1e123537e6ef49b3ec7caa6c095c62272':
// Mistral-Nemo-Instruct-2407
'Mistral V3-Tekken'
,
'26a59556925c987317ce5291811ba3b7f32ec4c647c400c6cc7e3a9993007ba7':
// Mistral-7B-Instruct-v0.3
'Mistral V2 & V3'
,
// 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'
,
};
const substr_derivations = {
'<|im_start|>': 'ChatML', // qwen2.5, ...
};
const parse_derivation = derivation => (typeof derivation === 'string') ? {
'context': derivation,
'instruct': derivation,
} : derivation;
export async function deriveTemplatesFromChatTemplate(chat_template, hash) {
if (chat_template.trim() === '') {
console.log('Missing chat template.');
return null;
}
if (hash in hash_derivations) {
return parse_derivation(hash_derivations[hash]);
}
// heuristics
for (const [substr, derivation] of Object.entries(substr_derivations) ) {
if (chat_template.includes(substr)) {
return parse_derivation(derivation);
}
}
console.warn(`Unknown chat template hash: ${hash} for [${chat_template}]`);
return null;
}

1639
public/scripts/chats.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
/**
* Common debounce timeout values to use with `debounce` calls.
* @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');

View File

@@ -0,0 +1,552 @@
import { getPresetManager } from './preset-manager.js';
import { extractMessageFromData, getGenerateUrl, getRequestHeaders } from '../script.js';
import { getTextGenServer } from './textgen-settings.js';
import { extractReasoningFromData } from './reasoning.js';
import { formatInstructModeChat, formatInstructModePrompt, getInstructStoppingSequences, names_behavior_types } from './instruct-mode.js';
import { getStreamingReply, tryParseStreamingError } 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} 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 for chat completion
* @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 for chat completion
*/
/** @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 = {
...props,
stream,
prompt,
max_tokens,
max_new_tokens: max_tokens,
model,
api_type,
api_server: api_server ?? getTextGenServer(api_type),
temperature,
min_p,
};
// 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 json;
}
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 };
}
};
}
/**
* Process and send a text completion request with optional preset & instruct
* @param {Record<string, any> & TextCompletionRequestBase & {prompt: (ChatCompletionMessage & {ignoreInstruct?: boolean})[] |string}} custom
* @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(
custom,
options = {},
extractData = true,
signal = null,
) {
const { presetName, instructName } = options;
let requestData = { ...custom };
const prompt = custom.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 parameters
const presetPayload = this.presetToGeneratePayload(preset, {});
requestData = { ...presetPayload, ...requestData };
} else {
console.warn(`Preset "${presetName}" not found, continuing with default settings`);
}
} else {
console.warn('Preset manager not found, continuing with default settings');
}
}
/** @type {InstructSettings | undefined} */
let instructPreset;
// Handle instruct formatting if requested
if (Array.isArray(prompt) && instructName) {
const instructPresetManager = getPresetManager('instruct');
instructPreset = instructPresetManager?.getCompletionPresetByName(instructName);
if (instructPreset) {
// Clone the preset to avoid modifying the original
instructPreset = structuredClone(instructPreset);
instructPreset.names_behavior = names_behavior_types.NONE;
if (options.instructSettings) {
Object.assign(instructPreset, options.instructSettings);
}
// Format messages using instruct formatting
const formattedMessages = [];
for (const message of prompt) {
let messageContent = message.content;
if (!message.ignoreInstruct) {
messageContent = formatInstructModeChat(
message.role,
message.content,
message.role === 'user',
false,
undefined,
undefined,
undefined,
undefined,
instructPreset,
);
// Add prompt formatting for the last message
if (message === prompt[prompt.length - 1]) {
messageContent += formatInstructModePrompt(
undefined,
false,
undefined,
undefined,
undefined,
false,
false,
instructPreset,
);
}
}
formattedMessages.push(messageContent);
}
requestData.prompt = formattedMessages.join('');
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 if (typeof prompt === 'string') {
requestData.prompt = prompt;
} else {
requestData.prompt = prompt.map(x => x.content).join('\n\n');
}
// @ts-ignore
const data = this.createRequestData(requestData);
const response = await this.sendRequest(data, extractData, signal);
// Remove stopping strings from the end
if (!data.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} customPreset - Additional parameters to override preset values
* @returns {Object} - Formatted payload for text completion API
*/
static presetToGeneratePayload(preset, customPreset = {}) {
if (!preset || typeof preset !== 'object') {
throw new Error('Invalid preset: must be an object');
}
// Merge preset with custom parameters
const settings = { ...preset, ...customPreset };
// Initialize base payload with common parameters
let payload = {
'temperature': settings.temp ? Number(settings.temp) : undefined,
'min_p': settings.min_p ? Number(settings.min_p) : undefined,
};
// Remove undefined values to avoid API errors
Object.keys(payload).forEach(key => {
if (payload[key] === undefined) {
delete payload[key];
}
});
return payload;
}
}
/**
* 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, ...props }) {
const payload = {
...props,
stream,
messages,
model,
chat_completion_source,
max_tokens,
temperature,
custom_url,
};
// 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 json;
}
if (!extractData) {
return json;
}
return {
content: extractMessageFromData(json, this.TYPE),
reasoning: extractReasoningFromData(json, {
mainApi: this.TYPE,
textGenType: data.chat_completion_source,
ignoreShowThoughts: true,
}),
};
}
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} custom
* @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(custom, options, extractData = true, signal = null) {
const { presetName } = options;
let requestData = { ...custom };
// 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
const presetPayload = this.presetToGeneratePayload(preset, {});
requestData = { ...presetPayload, ...requestData };
} else {
console.warn(`Preset "${presetName}" not found, continuing with default settings`);
}
} else {
console.warn('Preset manager not found, continuing with default settings');
}
}
const data = this.createRequestData(requestData);
return await this.sendRequest(data, extractData, signal);
}
/**
* Converts a preset to a valid chat completion payload
* Only supports temperature.
* @param {Object} preset - The preset configuration
* @param {Object} customParams - Additional parameters to override preset values
* @returns {Object} - Formatted payload for chat completion API
*/
static presetToGeneratePayload(preset, customParams = {}) {
if (!preset || typeof preset !== 'object') {
throw new Error('Invalid preset: must be an object');
}
// Merge preset with custom parameters
const settings = { ...preset, ...customParams };
// Initialize base payload with common parameters
const payload = {
temperature: settings.temperature ? Number(settings.temperature) : undefined,
};
// Remove undefined values to avoid API errors
Object.keys(payload).forEach(key => {
if (payload[key] === undefined) {
delete payload[key];
}
});
return payload;
}
}

107
public/scripts/dragdrop.js vendored Normal file
View 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);
}
}

View File

@@ -0,0 +1,162 @@
/** @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 } = {}) {
/** @type {{baseSelector: string, rule: CSSStyleRule}[]} */
const hoverRules = [];
/** @type {Set<string>} */
const focusRules = new Set();
const PLACEHOLDER = ':__PLACEHOLDER__';
/**
* Processes the CSS rules and separates selectors for hover and focus
* @param {CSSRuleList} rules - The CSS rules to process
*/
function processRules(rules) {
Array.from(rules).forEach(rule => {
if (rule instanceof CSSImportRule) {
// Make sure that @import rules are processed recursively
processImportedStylesheet(rule.styleSheet);
} 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', PLACEHOLDER).trim();
hoverRules.push({ baseSelector, rule });
} 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', PLACEHOLDER).replace(':focus-visible', PLACEHOLDER).replace(':focus', PLACEHOLDER).trim();
focusRules.add(baseSelector);
}
});
} else if (rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule) {
// Recursively process nested rules
processRules(rule.cssRules);
}
});
}
/**
* Processes the CSS rules of an imported stylesheet recursively
* @param {CSSStyleSheet} sheet - The imported stylesheet to process
*/
function processImportedStylesheet(sheet) {
if (sheet && sheet.cssRules) {
processRules(sheet.cssRules);
}
}
processRules(styleSheet.cssRules);
/** @type {CSSStyleSheet} */
let targetStyleSheet = null;
// Now finally create the dynamic focus rules
hoverRules.forEach(({ baseSelector, rule }) => {
if (!focusRules.has(baseSelector)) {
// 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');
const focusRule = `${focusSelector} { ${rule.style.cssText} }`;
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);
}
});
}

View File

@@ -0,0 +1,320 @@
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
* @returns {(args: {[key: string]: string | SlashCommandClosure}, 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);
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) ? 'enable' :
isFalseBoolean(args?.state) ? '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 ChuQuadrant-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.',
}));
}

1437
public/scripts/extensions.js Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,472 @@
/*
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 { executeSlashCommands } from '../../slash-commands.js';
import { accountStorage } from '../../util/AccountStorage.js';
import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
import { t } 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/ChuQuadrant/ChuQuadrant-Content/main/index.json';
// DBG
//if (DEBUG_TONY_SAMA_FORK_MODE)
// ASSETS_JSON_URL = "https://raw.githubusercontent.com/Tony-sama/ChuQuadrant-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': 'Extensions',
'character': 'Characters',
'ambient': 'Ambient sounds',
'bgm': 'Background music',
'blip': 'Blip sounds',
};
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 option = $('<option />', { value: type, text: t([KNOWN_TYPES[type] || type]) });
$('#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 i in availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['name'].localeCompare(b['name']))) {
const asset = availableAssets[assetType][i];
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/ChuQuadrant/","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 executeSlashCommands(`/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 assetBlock = $('<i></i>')
.append(element)
.append(`<div class="flex-container flexFlowColumn flexNoGap">
<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>
<small class="asset-description">
${description}
</small>
</div>`);
assetBlock.find('.tag').on('click', function (e) {
const a = document.createElement('a');
a.href = 'https://docs.ChuQuadrant.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);
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], true);
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(),
});
currentAssets = result.ok ? (await result.json()) : {};
}
catch (err) {
console.log(err);
}
console.debug(DEBUG_PREFIX, 'Current assets found:', currentAssets);
}
//#############################//
// Extension load //
//#############################//
// This function is called when the extension is loaded
jQuery(async () => {
// This is an example of loading HTML from a file
const windowTemplate = await renderExtensionTemplateAsync(MODULE_NAME, 'window', {});
const windowHtml = $(windowTemplate);
const assetsJsonUrl = windowHtml.find('#assets-json-url-field');
assetsJsonUrl.val(ASSETS_JSON_URL);
const charactersButton = windowHtml.find('#assets-characters-button');
charactersButton.on('click', async function () {
openCharacterBrowser(false);
});
const installHintButton = windowHtml.find('.assets-install-hint-link');
installHintButton.on('click', async function () {
const installButton = $('#third_party_extension_button');
flashHighlight(installButton, 5000);
toastr.info(t`Click the flashing button to install extensions.`, t`How to install extensions?`);
});
const connectButton = windowHtml.find('#assets-connect-button');
connectButton.on('click', async function () {
const url = DOMPurify.sanitize(String(assetsJsonUrl.val()));
const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '<span>' + t`Are you sure you want to connect to the following url?` + `</span><var>${url}</var>`, {
customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
onClose: popup => {
if (popup.result) {
const rememberValue = popup.inputResults.get('assets-remember');
accountStorage.setItem(rememberKey, String(rememberValue));
}
},
});
if (confirmation) {
try {
console.debug(DEBUG_PREFIX, 'Confimation, loading assets...');
downloadAssetsList(url);
connectButton.removeClass('fa-plug-circle-exclamation');
connectButton.removeClass('redOverlayGlow');
connectButton.addClass('fa-plug-circle-check');
} catch (error) {
console.error('Error:', error);
toastr.error(`Cannot get assets list from ${url}`);
connectButton.removeClass('fa-plug-circle-check');
connectButton.addClass('fa-plug-circle-exclamation');
connectButton.removeClass('redOverlayGlow');
}
}
else {
console.debug(DEBUG_PREFIX, 'Connection refused by user');
}
});
windowHtml.find('#assets_filters').hide();
$('#assets_container').append(windowHtml);
eventSource.on(event_types.OPEN_CHARACTER_LIBRARY, async (forceDefault) => {
openCharacterBrowser(forceDefault);
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
#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: 5px;
font-style: normal;
gap: 5px;
}
.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 0.2s;
}
.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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,381 @@
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();
}
}
jQuery(async () => {
eventSource.on(event_types.APP_READY, cleanUpAttachments);
const manageButton = await renderExtensionTemplateAsync('attachments', 'manage-button', {});
const attachButton = await renderExtensionTemplateAsync('attachments', 'attach-button', {});
$('#data_bank_wand_container').append(manageButton);
$('#attach_file_wand_container').append(attachButton);
/** A collection of local enum providers for this context of data bank */
const localEnumProviders = {
/**
* All attachments in the data bank based on the source argument. If not provided, defaults to 'chat'.
* @param {'name' | 'url'} returnField - Whether the enum should return the 'name' field or the 'url'
* @param {'chat' | 'character' | 'global' | ''} fallbackSource - The source to use if the source argument is not provided. Empty string to use all sources.
* */
attachments: (returnField = 'name', fallbackSource = 'chat') => (/** @type {SlashCommandExecutor} */ executor) => {
const source = executor.namedArgumentList.find(it => it.name == 'source')?.value ?? fallbackSource;
if (source instanceof SlashCommandClosure) throw new Error('Argument \'source\' does not support closures');
const attachments = getAttachments(source);
return attachments.map(attachment => new SlashCommandEnumValue(
returnField === 'name' ? attachment.name : attachment.url,
`${enumIcons.getStateIcon(!extension_settings.disabled_attachments.includes(attachment.url))} [${source}] ${returnField === 'url' ? attachment.name : attachment.url}`,
enumTypes.enum, enumIcons.file));
},
};
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db',
callback: () => {
document.getElementById('manageAttachments')?.click();
return '';
},
aliases: ['databank', 'data-bank'],
helpString: 'Open the data bank',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-list',
callback: listDataBankAttachments,
aliases: ['databank-list', 'data-bank-list'],
helpString: 'List attachments in the Data Bank as a JSON-serialized array. Optionally, provide the source of the attachments and the field to list by.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachments.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
new SlashCommandNamedArgument('field', 'The field to list by.', ARGUMENT_TYPE.STRING, false, false, 'url', FIELDS),
],
returns: ARGUMENT_TYPE.LIST,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-get',
callback: getDataBankText,
aliases: ['databank-get', 'data-bank-get'],
helpString: 'Get attachment text from the Data Bank. Either provide the name or URL of the attachment. Optionally, provide the source of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name or URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
acceptsMultiple: false,
enumProvider: localEnumProviders.attachments('name', ''),
}),
],
returns: ARGUMENT_TYPE.STRING,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-add',
callback: uploadDataBankAttachment,
aliases: ['databank-add', 'data-bank-add'],
helpString: 'Add an attachment to the Data Bank. If name is not provided, it will be generated automatically. Returns the URL of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source for the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
new SlashCommandNamedArgument('name', 'The name of the attachment.', ARGUMENT_TYPE.STRING, false, false),
],
unnamedArgumentList: [
new SlashCommandArgument('The content of the file attachment.', ARGUMENT_TYPE.STRING, true, false),
],
returns: ARGUMENT_TYPE.STRING,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-update',
callback: updateDataBankAttachment,
aliases: ['databank-update', 'data-bank-update'],
helpString: 'Update an attachment in the Data Bank, preserving its name. Returns a new URL of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source for the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
SlashCommandNamedArgument.fromProps({
name: 'name',
description: 'The name of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: localEnumProviders.attachments('name'),
}),
SlashCommandNamedArgument.fromProps({
name: 'url',
description: 'The URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: localEnumProviders.attachments('url'),
}),
],
unnamedArgumentList: [
new SlashCommandArgument('The content of the file attachment.', ARGUMENT_TYPE.STRING, true, false),
],
returns: ARGUMENT_TYPE.STRING,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-disable',
callback: disableDataBankAttachment,
aliases: ['databank-disable', 'data-bank-disable'],
helpString: 'Disable an attachment in the Data Bank by its name or URL. Optionally, provide the source of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name or URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.attachments('name', ''),
}),
],
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-enable',
callback: enableDataBankAttachment,
aliases: ['databank-enable', 'data-bank-enable'],
helpString: 'Enable an attachment in the Data Bank by its name or URL. Optionally, provide the source of the attachment.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name or URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.attachments('name', ''),
}),
],
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'db-delete',
callback: deleteDataBankAttachment,
aliases: ['databank-delete', 'data-bank-delete'],
helpString: 'Delete an attachment from the Data Bank.',
namedArgumentList: [
new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The name or URL of the attachment.',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.attachments(),
}),
],
}));
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,650 @@
import { ensureImageFormatSupported, getBase64Async, isTrueBoolean, saveBase64AsFile } from '../../utils.js';
import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';
import { appendMediaToMessage, callPopup, 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';
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;
}
}
/**
* 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 callPopup(
'<h3>Review and edit the generated caption:</h3>Press "Cancel" to abort the caption sending.',
'input',
messageText,
{ rows: 5, okButton: 'Send' });
if (!messageText) {
throw new Error('User aborted the caption sending.');
}
}
return messageText;
}
/**
* Appends caption to an existing message.
* @param {Object} data Message data
* @returns {Promise<void>}
*/
async function captionExistingMessage(data) {
if (!(data?.extra?.image)) {
return;
}
const imageData = await fetch(data.extra.image);
const blob = await imageData.blob();
const type = imageData.headers.get('Content-Type');
const file = new File([blob], 'image.png', { 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(data.mes).trim();
if (!messageText) {
data.extra.inline_image = false;
data.mes = wrappedCaption;
data.extra.title = wrappedCaption;
}
else {
data.extra.inline_image = true;
data.extra.append_title = true;
data.extra.title = wrappedCaption;
}
}
/**
* Sends a captioned message to the chat.
* @param {string} caption Caption text
* @param {string} image Image URL
*/
async function sendCaptionedMessage(caption, image) {
const messageText = await wrapCaptionTemplate(caption);
const context = getContext();
const message = {
name: context.name1,
is_user: true,
send_date: getMessageTimeStamp(),
mes: messageText,
extra: {
image: image,
title: messageText,
},
};
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();
}
/**
* 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 callPopup('<h3>Enter a comment or question:</h3>', 'input', prompt, { rows: 2 });
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 {
setSpinnerIcon();
const context = getContext();
const fileData = await getBase64Async(await ensureImageFormatSupported(file));
const base64Format = fileData.split(',')[0].split(';')[0].split('/')[1];
const base64Data = fileData.split(',')[1];
const { caption } = await doCaptionRequest(base64Data, fileData, prompt);
if (!quiet) {
const imagePath = await saveBase64AsFile(base64Data, context.name2, '', base64Format);
await sendCaptionedMessage(caption, imagePath);
}
return caption;
}
catch (error) {
const errorMessage = error.message || 'Unknown error';
toastr.error(errorMessage, 'Failed to caption image.');
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 mesId = args?.mesId ?? args?.id;
if (!isNaN(Number(mesId))) {
const message = getContext().chat[mesId];
if (message?.extra?.image) {
try {
const fetchResult = await fetch(message.extra.image);
const blob = await fetchResult.blob();
const file = new File([blob], 'image.jpg', { 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/*';
input.onchange = async (e) => {
const caption = await onSelectImage(e, prompt, quiet);
resolve(caption);
};
input.oncancel = () => resolve('');
input.click();
});
}
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;
// APIs that support reverse proxy
const reverseProxyApis = {
'openai': SECRET_KEYS.OPENAI,
'mistral': SECRET_KEYS.MISTRALAI,
'google': SECRET_KEYS.MAKERSUITE,
'anthropic': SECRET_KEYS.CLAUDE,
};
if (reverseProxyApis[api]) {
if (secret_state[reverseProxyApis[api]] || settings.allow_reverse_proxy) {
return true;
}
}
const chatCompletionApis = {
'openrouter': SECRET_KEYS.OPENROUTER,
'zerooneai': SECRET_KEYS.ZEROONEAI,
'groq': SECRET_KEYS.GROQ,
'cohere': SECRET_KEYS.COHERE,
};
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] && textgenerationwebui_settings.server_urls[textCompletionApis[api]]) {
return true;
}
// Custom API doesn't need additional checks
if (api === 'custom') {
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 inputHtml = '<input id="img_file" type="file" hidden accept="image/*">';
const imgForm = document.createElement('form');
imgForm.id = 'img_form';
$(imgForm).append(inputHtml);
$(imgForm).hide();
$('#form_sheld').append(imgForm);
$('#img_file').on('change', (e) => onSelectImage(e.originalEvent, '', false));
}
async function switchMultimodalBlocks() {
await addOpenRouterModels();
const isMultimodal = extension_settings.caption.source === 'multimodal';
$('#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 addOpenRouterModels() {
const dropdown = document.getElementById('caption_multimodal_model');
if (!(dropdown instanceof HTMLSelectElement)) {
return;
}
if (extension_settings.caption.source !== 'multimodal' || extension_settings.caption.multimodal_api !== 'openrouter') {
return;
}
const options = Array.from(dropdown.options);
const response = await fetch('/api/openrouter/models/multimodal', {
method: 'POST',
headers: getRequestHeaders(),
});
if (!response.ok) {
return;
}
const modelIds = await response.json();
if (Array.isArray(modelIds) && modelIds.length > 0) {
modelIds.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 = 'openrouter';
dropdown.add(option);
});
}
}
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', () => {
extension_settings.caption.source = String($('#caption_source').val());
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 presetModel = extension_settings.caption.multimodal_model !== 'ollama_current' ? extension_settings.caption.multimodal_model : '';
e.preventDefault();
$('#ollama_download_model').trigger('click');
$('#dialogue_popup_input').val(presetModel);
});
$('#caption_multimodal_api').on('change', () => {
const api = String($('#caption_multimodal_api').val());
const model = String($(`#caption_multimodal_model option[data-type="${api}"]`).first().val());
extension_settings.caption.multimodal_api = api;
extension_settings.caption.multimodal_model = model;
saveSettingsDebounced();
switchMultimodalBlocks();
});
$('#caption_multimodal_model').on('change', () => {
extension_settings.caption.multimodal_model = String($('#caption_multimodal_model').val());
saveSettingsDebounced();
});
const onMessageEvent = async (index) => {
if (!extension_settings.caption.auto_mode) {
return;
}
const data = getContext().chat[index];
await captionExistingMessage(data);
};
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 messageImg = messageBlock.find('.mes_img');
if (messageImg.hasClass(animationClass)) return;
messageImg.addClass(animationClass);
try {
const index = Number(messageBlock.attr('mesid'));
const data = getContext().chat[index];
await captionExistingMessage(data);
appendMediaToMessage(data, messageBlock, false);
await saveChatConditional();
} catch (e) {
console.error('Message image recaption failed', e);
} finally {
messageImg.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(),
}),
],
unnamedArgumentList: [
new SlashCommandArgument(
'prompt', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Caption an image with an optional prompt and passes the caption down the pipe.
</div>
<div>
Only multimodal sources support custom prompts.
</div>
<div>
Provide a message ID to get an image from a message instead of uploading one.
</div>
<div>
Set the "quiet" argument to true to suppress sending a captioned message, default: false.
</div>
`,
}));
document.body.classList.add('caption');
});

View File

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

View File

@@ -0,0 +1,170 @@
<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 (OpenAI / Anthropic / llama / Google)">Multimodal (OpenAI / Anthropic / llama / Google)</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="zerooneai">01.AI (Yi)</option>
<option value="anthropic">Anthropic</option>
<option value="cohere">Cohere</option>
<option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>
<option value="google">Google AI Studio</option>
<option value="groq">Groq</option>
<option value="koboldcpp">KoboldCpp</option>
<option value="llamacpp">llama.cpp</option>
<option value="mistral">MistralAI</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="vllm">vLLM</option>
</select>
</div>
<div class="flex1 flex-container flexFlowColumn flexNoGap">
<label for="caption_multimodal_model" data-i18n="Model">Model</label>
<select id="caption_multimodal_model" class="flex1 text_pole">
<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="mistral" value="pixtral-12b-latest">pixtral-12b-latest</option>
<option data-type="mistral" value="pixtral-12b-2409">pixtral-12b-2409</option>
<option data-type="mistral" value="pixtral-large-latest">pixtral-large-latest</option>
<option data-type="mistral" value="pixtral-large-2411">pixtral-large-2411</option>
<option data-type="mistral" value="mistral-large-pixtral-2411">mistral-large-pixtral-2411</option>
<option data-type="mistral" value="mistral-small-2503">mistral-small-2503</option>
<option data-type="mistral" value="mistral-small-latest">mistral-small-latest</option>
<option data-type="zerooneai" value="yi-vision">yi-vision</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="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="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-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-sonnet-20240229">claude-3-sonnet-20240229</option>
<option data-type="anthropic" value="claude-3-haiku-20240307">claude-3-haiku-20240307</option>
<option data-type="google" value="gemini-2.5-pro-exp-03-25">gemini-2.5-pro-exp-03-25</option>
<option data-type="google" value="gemini-2.0-pro-exp">gemini-2.0-pro-exp</option>
<option data-type="google" value="gemini-2.0-pro-exp-02-05">gemini-2.0-pro-exp-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="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">gemini-2.0-flash</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">gemini-2.0-flash-exp</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-thinking-exp">gemini-2.0-flash-thinking-exp</option>
<option data-type="google" value="gemini-2.0-flash-thinking-exp-01-21">gemini-2.0-flash-thinking-exp-01-21</option>
<option data-type="google" value="gemini-2.0-flash-thinking-exp-1219">gemini-2.0-flash-thinking-exp-1219</option>
<option data-type="google" value="gemini-1.5-flash">gemini-1.5-flash</option>
<option data-type="google" value="gemini-1.5-flash-latest">gemini-1.5-flash-latest</option>
<option data-type="google" value="gemini-1.5-flash-001">gemini-1.5-flash-001</option>
<option data-type="google" value="gemini-1.5-flash-002">gemini-1.5-flash-002</option>
<option data-type="google" value="gemini-1.5-flash-exp-0827">gemini-1.5-flash-exp-0827</option>
<option data-type="google" value="gemini-1.5-flash-8b-exp-0827">gemini-1.5-flash-8b-exp-0827</option>
<option data-type="google" value="gemini-1.5-flash-8b-exp-0924">gemini-1.5-flash-8b-exp-0924</option>
<option data-type="google" value="gemini-exp-1114">gemini-exp-1114</option>
<option data-type="google" value="gemini-exp-1121">gemini-exp-1121</option>
<option data-type="google" value="gemini-exp-1206">gemini-exp-1206</option>
<option data-type="google" value="gemini-1.5-pro">gemini-1.5-pro</option>
<option data-type="google" value="gemini-1.5-pro-latest">gemini-1.5-pro-latest</option>
<option data-type="google" value="gemini-1.5-pro-001">gemini-1.5-pro-001</option>
<option data-type="google" value="gemini-1.5-pro-002">gemini-1.5-pro-002</option>
<option data-type="google" value="gemini-1.5-pro-exp-0801">gemini-1.5-pro-exp-0801</option>
<option data-type="google" value="gemini-1.5-pro-exp-0827">gemini-1.5-pro-exp-0827</option>
<option data-type="groq" value="llama-3.2-11b-vision-preview">llama-3.2-11b-vision-preview</option>
<option data-type="groq" value="llama-3.2-90b-vision-preview">llama-3.2-90b-vision-preview</option>
<option data-type="groq" value="llava-v1.5-7b-4096-preview">llava-v1.5-7b-4096-preview</option>
<option data-type="openrouter" value="openai/gpt-4-vision-preview">openai/gpt-4-vision-preview</option>
<option data-type="openrouter" value="openai/gpt-4o">openai/gpt-4o</option>
<option data-type="openrouter" value="openai/gpt-4o-2024-05-13">openai/gpt-4o-2024-05-13</option>
<option data-type="openrouter" value="openai/gpt-4o-2024-08-06">openai/gpt-4o-2024-08-06</option>
<option data-type="openrouter" value="openai/gpt-4-turbo">openai/gpt-4-turbo</option>
<option data-type="openrouter" value="openai/gpt-4o-mini">openai/gpt-4o-mini</option>
<option data-type="openrouter" value="openai/gpt-4o-mini-2024-07-18">openai/gpt-4o-mini-2024-07-18</option>
<option data-type="openrouter" value="openai/chatgpt-4o-latest">openai/chatgpt-4o-latest</option>
<option data-type="openrouter" value="haotian-liu/llava-13b">haotian-liu/llava-13b</option>
<option data-type="openrouter" value="fireworks/firellava-13b">fireworks/firellava-13b</option>
<option data-type="openrouter" value="anthropic/claude-3.5-sonnet">anthropic/claude-3.5-sonnet</option>
<option data-type="openrouter" value="anthropic/claude-3-haiku">anthropic/claude-3-haiku</option>
<option data-type="openrouter" value="anthropic/claude-3-sonnet">anthropic/claude-3-sonnet</option>
<option data-type="openrouter" value="anthropic/claude-3-opus">anthropic/claude-3-opus</option>
<option data-type="openrouter" value="anthropic/claude-3.5-sonnet:beta">anthropic/claude-3.5-sonnet:beta</option>
<option data-type="openrouter" value="anthropic/claude-3-haiku:beta">anthropic/claude-3-haiku:beta</option>
<option data-type="openrouter" value="anthropic/claude-3-sonnet:beta">anthropic/claude-3-sonnet:beta</option>
<option data-type="openrouter" value="anthropic/claude-3-opus:beta">anthropic/claude-3-opus:beta</option>
<option data-type="openrouter" value="nousresearch/nous-hermes-2-vision-7b">nousresearch/nous-hermes-2-vision-7b</option>
<option data-type="openrouter" value="google/gemini-flash-8b-1.5-exp">google/gemini-flash-8b-1.5-exp</option>
<option data-type="openrouter" value="google/gemini-flash-1.5">google/gemini-flash-1.5</option>
<option data-type="openrouter" value="google/gemini-flash-1.5-exp">google/gemini-flash-1.5-exp</option>
<option data-type="openrouter" value="google/gemini-pro-1.5">google/gemini-pro-1.5</option>
<option data-type="openrouter" value="google/gemini-pro-1.5-exp">google/gemini-pro-1.5-exp</option>
<option data-type="openrouter" value="google/gemini-pro-vision">google/gemini-pro-vision</option>
<option data-type="openrouter" value="liuhaotian/llava-yi-34b">liuhaotian/llava-yi-34b</option>
<option data-type="ollama" value="ollama_current" data-i18n="currently_selected">[Currently selected]</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="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">
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>
<label data-type="openai,anthropic,google,mistral" 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 m-b-1">
<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>
<div id="caption_prompt_block">
<label for="caption_prompt" data-i18n="Caption Prompt">Caption Prompt</label>
<textarea id="caption_prompt" class="text_pole" rows="1" placeholder="&lt; Use default &gt;">{{PROMPT_DEFAULT}}</textarea>
<label class="checkbox_label margin-bot-10px" for="caption_prompt_ask" title="Ask for a custom prompt every time an image is captioned.">
<input id="caption_prompt_ask" type="checkbox" class="checkbox">
<span data-i18n="Ask every time">Ask every time</span>
</label>
</div>
<label for="caption_template"><span data-i18n="Message Template">Message Template</span> <small><span data-i18n="(use _space">(use </span> <code>&lcub;&lcub;caption&rcub;&rcub;</code> <span data-i18n="macro)">macro)</span></small></label>
<textarea id="caption_template" class="text_pole" rows="2" placeholder="&lt; Use default &gt;">{{TEMPLATE_DEFAULT}}</textarea>
<label class="checkbox_label" for="caption_auto_mode">
<input id="caption_auto_mode" type="checkbox" class="checkbox">
<span data-i18n="Automatically caption images">Automatically caption images</span>
<i class="fa-solid fa-info-circle" title="Automatically caption images when they are pasted into the chat or attached to messages."></i>
</label>
<label class="checkbox_label margin-bot-10px" 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>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,782 @@
import { DOMPurify, Fuse } from '../../../lib.js';
import { event_types, eventSource, main_api, 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 } from '../../utils.js';
import { t } from '../../i18n.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',
];
const TC_COMMANDS = [
'api',
'preset',
'api-url',
'model',
'sysprompt',
'sysprompt-state',
'instruct',
'context',
'instruct-state',
'tokenizer',
'stop-strings',
'start-reply-with',
'reasoning-template',
];
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',
};
/**
* 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[]} [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, { rows: 2 });
// 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;
}
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, {
rows: 2,
customButtons: [{
text: t`Save and Update`,
classes: ['popup-button-ok'],
result: POPUP_RESULT.AFFIRMATIVE,
action: () => {
saveChanges = true;
},
}],
});
// If it's cancelled, it will be false
if (!newName) {
return;
}
newName = DOMPurify.sanitize(String(newName));
if (!newName) {
toastr.error('Name cannot be empty.');
return;
}
if (profile.name !== newName && extension_settings.connectionManager.profiles.some(p => p.name === newName)) {
toastr.error('A profile with the same name already exists.');
return;
}
const newExcludeList = template.find('input[name="exclude"]:not(:checked)').map(function () {
return Object.entries(FANCY_NAMES).find(x => x[1] === String($(this).val()))?.[0];
}).get();
const oldProfile = structuredClone(profile);
if (newExcludeList.length !== profile.exclude.length || !newExcludeList.every(e => profile.exclude.includes(e))) {
profile.exclude = newExcludeList;
for (const command of newExcludeList) {
delete profile[command];
}
if (saveChanges) {
await updateConnectionProfile(profile);
} else {
toastr.info('Press "Update" to record them into the profile.', 'Included settings list updated');
}
}
if (profile.name !== newName) {
toastr.success('Connection profile renamed.');
profile.name = newName;
}
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
});
/** @type {HTMLElement} */
const viewDetails = document.getElementById('view_connection_profile');
const detailsContent = document.getElementById('connection_profile_details_content');
viewDetails.addEventListener('click', async () => {
viewDetails.classList.toggle('active');
detailsContent.classList.toggle('hidden');
await renderDetailsContent(detailsContent);
});
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile',
helpString: 'Switch to a connection profile or return the name of the current profile in no argument is provided. Use <code>&lt;None&gt;</code> to switch to no profile.',
returns: 'name of the profile',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Name of the connection profile',
enumProvider: profilesProvider,
isRequired: false,
}),
],
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'await',
description: 'Wait for the connection profile to be applied before returning.',
isRequired: false,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
],
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;
}
return profile.name;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-list',
helpString: 'List all connection profile names.',
returns: 'list of profile names',
callback: () => JSON.stringify(extension_settings.connectionManager.profiles.map(p => p.name)),
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-create',
returns: 'name of the new profile',
helpString: 'Create a new connection profile using the current settings.',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'name of the new connection profile',
isRequired: true,
typeList: [ARGUMENT_TYPE.STRING],
}),
],
callback: async (_args, name) => {
if (!name || typeof name !== 'string') {
toastr.warning('Please provide a name for the new connection profile.');
return '';
}
const profile = await createConnectionProfile(name);
if (!profile) {
return '';
}
extension_settings.connectionManager.profiles.push(profile);
extension_settings.connectionManager.selectedProfile = profile.id;
saveSettingsDebounced();
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
return profile.name;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-update',
helpString: 'Update the selected connection profile.',
callback: async () => {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
toastr.warning('No profile selected.');
return '';
}
const oldProfile = structuredClone(profile);
await updateConnectionProfile(profile);
await renderDetailsContent(detailsContent);
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
return profile.name;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-get',
helpString: 'Get the details of the connection profile. Returns the selected profile if no argument is provided.',
returns: 'object of the selected profile',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Name of the connection profile',
enumProvider: profilesProvider,
isRequired: false,
}),
],
callback: async (_args, value) => {
if (!value || typeof value !== 'string') {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
return '';
}
return JSON.stringify(profile);
}
const profile = findProfileByName(value);
if (!profile) {
return '';
}
return JSON.stringify(profile);
},
}));
})();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
<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="Will be used if the API doesn't support JSON schemas or function calling.">Will be used if the API doesn't support JSON schemas or function calling.</small>
<textarea id="expression_llm_prompt" type="text" class="text_pole textarea_compact" rows="2" placeholder="Use &lcub;&lcub;labels&rcub;&rcub; special macro."></textarea>
</div>
<div class="expression_fallback_block m-b-1 m-t-1">
<label for="expression_fallback" data-i18n="Default / Fallback Expression">Default / Fallback Expression</label>
<small data-i18n="Set the default and fallback expression being used when no matching expression is found.">Set the default and fallback expression being used when no matching expression is found.</small>
<select id="expression_fallback" class="flex1 margin0"></select>
</div>
<div class="expression_custom_block m-b-1 m-t-1">
<label for="expression_custom" data-i18n="Custom Expressions">Custom Expressions</label>
<small><span data-i18n="Can be set manually or with an _space">Can be set manually or with an </span><tt>/emote</tt><span data-i18n="space_ slash command."> slash command.</span></small>
<div class="flex-container">
<select id="expression_custom" class="flex1 margin0"><select>
<i id="expression_custom_add" class="menu_button fa-solid fa-plus margin0" title="Add"></i>
<i id="expression_custom_remove" class="menu_button fa-solid fa-xmark margin0" title="Remove"></i>
</div>
</div>
<div id="no_chat_expressions" data-i18n="Open a chat to see the character expressions.">
Open a chat to see the character expressions.
</div>
<div id="open_chat_expressions">
<div class="offline_mode">
<small data-i18n="You are in offline mode. Click on the image below to set the expression.">You are in offline mode. Click on the image below to set the expression.</small>
</div>
<label for="expression_override" data-i18n="Sprite Folder Override">Sprite Folder Override</label>
<small><span data-i18n="Use a forward slash to specify a subfolder. Example: _space">Use a forward slash to specify a subfolder. Example: </span><tt>Bob/formal</tt></small>
<div class="flex-container flexnowrap">
<input id="expression_override" type="text" class="text_pole" placeholder="Override folder name" />
<input id="expression_override_button" class="menu_button" type="submit" value="Submit" />
</div>
<div class="expression_buttons flex-container spaceEvenly">
<div id="expression_upload_pack_button" class="menu_button">
<i class="fa-solid fa-file-zipper"></i>
<span data-i18n="Upload sprite pack (ZIP)">Upload sprite pack (ZIP)</span>
</div>
<div id="expression_override_cleanup_button" class="menu_button">
<i class="fa-solid fa-trash-can"></i>
<span data-i18n="Remove all image overrides">Remove all image overrides</span>
</div>
</div>
<p class="hint">
<b data-i18n="Hint:">Hint:</b>
<i>
<span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
<span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt>
</i>
</p>
<p>
<i>
<span>In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a
dash.
Examples: </span><tt>joy.png</tt>, <tt>joy-1.png</tt>, <tt>joy.expressive.png</tt>
</i>
</p>
<h3 id="image_list_header">
<strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>
</h3>
<div id="image_list"></div>
</div>
</div>
</div>
<form>
<input type="file" id="expression_upload_pack" name="expression_upload_pack" accept="application/zip" hidden>
<input type="file" id="expression_upload" name="expression_upload" accept="image/*" hidden>
</form>
</div>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,726 @@
import {
eventSource,
this_chid,
characters,
getRequestHeaders,
event_types,
} from '../../../script.js';
import { groups, selected_group } from '../../group-chats.js';
import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } 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';
const extensionName = 'gallery';
const extensionFolderPath = `scripts/extensions/${extensionName}/`;
let firstTime = true;
// Exposed defaults for future tweaking
let thumbnailHeight = 150;
let paginationVisiblePages = 10;
let paginationMaxLinesPerPage = 2;
let galleryMaxRows = 3;
// Remove all draggables associated with the gallery
$('#movingDivs').on('click', '.dragClose', function () {
const relatedId = $(this).data('related-id');
if (!relatedId) return;
$(`#movingDivs > .draggable[id="${relatedId}"]`).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`Sort By: Name (A-Z)` },
NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Sort By: Name (Z-A)` },
DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Sort By: Date (Oldest First)` },
DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Sort By: Date (Newest First)` },
});
const defaultSettings = Object.freeze({
folders: {},
sort: SORT.DATE_ASC.value,
});
/**
* Initializes the settings for the gallery extension.
*/
function initSettings() {
let shouldSave = false;
const context = ChuQuadrant.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 {import('../../char-data.js').v1CharData} char Character data
* @returns {string} The gallery folder for the character
*/
function getGalleryFolder(char) {
return ChuQuadrant.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,
}),
});
url = await getSanitizedFilename(url);
const data = await response.json();
const items = data.map((file) => ({
src: `user/images/${url}/${file}`,
srct: `user/images/${url}/${file}`,
title: '', // Optional title for each 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(),
});
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 [];
}
}
/**
* Sets the sort order for the gallery.
* @param {string} order Sort order
*/
function setSortOrder(order) {
const context = ChuQuadrant.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 ChuQuadrant.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) {
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,
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() {
// 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 {
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.trace();
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 base64Data = await getBase64Async(file);
// Create the payload
const payload = {
image: base64Data,
ch_name: url,
};
const response = await fetch('/api/images/upload', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const result = await response.json();
toastr.success(t`File uploaded successfully. Saved at: ${result.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)');
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);
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();
dragTitle.append(sortSelect);
// 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 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(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);
$(`.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 = ChuQuadrant.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 = ChuQuadrant.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 imgElem = document.createElement('img');
imgElem.src = url;
let uniqueId = `draggable_${id}`;
const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable'));
if (draggableElem) {
draggableElem.appendChild(imgElem);
// Find a unique id for the draggable element
let counter = 1;
while (document.getElementById(uniqueId)) {
uniqueId = `draggable_${id}_${counter}`;
counter++;
}
draggableElem.id = uniqueId;
// 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
// 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.trace();
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 = ChuQuadrant.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 = ChuQuadrant.getContext();
delete context.extensionSettings.gallery.folders[avatar];
context.saveSettingsDebounced();
});
eventSource.on('charManagementDropdown', (selectedOptionId) => {
if (selectedOptionId === 'show_char_gallery') {
showCharGallery();
}
});
// Add an option to the dropdown
$('#char-management-dropdown').append(
$('<option>', {
id: 'show_char_gallery',
text: translate('Show Gallery'),
}),
);
})();

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,45 @@
.nGY2 .nGY2GalleryBottom {
display: flex;
align-items: center;
justify-content: center;
}
.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: none;
background-image: url(/img/down-arrow.svg);
background-repeat: no-repeat;
background-position: right 6px center;
background-size: 8px 5px;
padding-right: 20px;
font-size: calc(var(--mainFontSize)* 0.9);
margin-bottom: 0;
}
#gallery .dragTitle {
margin-right: 30px;
}
#dragGallery {
min-height: 25dvh;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,505 @@
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 ChuQuadrant 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 {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,
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.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 ChuQuadrant 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 {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,
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.automationId = automationId ?? qr.automationId;
qr.onUpdate();
return qr;
}
/**
* Deletes an existing quick reply.
*
* @param {string} setName name of the existing quick reply set
* @param {string|number} label label of the existing quick reply (text on the button) or its numeric ID
*/
deleteQuickReply(setName, label) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
qr.delete();
}
/**
* Adds an existing quick reply set as a context menu to an existing quick reply.
*
* @param {string} setName name of the existing quick reply set containing the quick reply
* @param {string|number} label label of the existing quick reply or its numeric ID
* @param {string} contextSetName name of the existing quick reply set to be used as a context menu
* @param {boolean} isChained whether or not to chain the context menu quick replies
*/
createContextItem(setName, label, contextSetName, isChained = false) {
const qr = this.getQrByLabel(setName, label);
const set = this.getSetByName(contextSetName);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
if (!set) {
throw new Error(`No quick reply set with name "${contextSetName}" found.`);
}
const cl = new QuickReplyContextLink();
cl.set = set;
cl.isChained = isChained;
qr.addContextLink(cl);
}
/**
* Removes a quick reply set from a quick reply's context menu.
*
* @param {string} setName name of the existing quick reply set containing the quick reply
* @param {string|number} label label of the existing quick reply or its numeric ID
* @param {string} contextSetName name of the existing quick reply set to be used as a context menu
*/
deleteContextItem(setName, label, contextSetName) {
const qr = this.getQrByLabel(setName, label);
const set = this.getSetByName(contextSetName);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
if (!set) {
throw new Error(`No quick reply set with name "${contextSetName}" found.`);
}
qr.removeContextLink(set.name);
}
/**
* Removes all entries from a quick reply's context menu.
*
* @param {string} setName name of the existing quick reply set containing the quick reply
* @param {string|number} label label of the existing quick reply or its numeric ID
*/
clearContextMenu(setName, label) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
throw new Error(`No quick reply with label "${label}" in set "${setName}" found.`);
}
qr.clearContextLinks();
}
/**
* Create a new quick reply set.
*
* @param {string} name name of the new quick reply set
* @param {object} [props]
* @param {boolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
* @param {boolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
* @param {boolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
* @returns {Promise<QuickReplySet>} the new quick reply set
*/
async createSet(name, {
disableSend,
placeBeforeInput,
injectInput,
} = {}) {
const set = new QuickReplySet();
set.name = name;
set.disableSend = disableSend ?? false;
set.placeBeforeInput = placeBeforeInput ?? false;
set.injectInput = injectInput ?? false;
const oldSet = this.getSetByName(name);
if (oldSet) {
QuickReplySet.list.splice(QuickReplySet.list.indexOf(oldSet), 1, set);
} else {
const idx = QuickReplySet.list.findIndex(it=>it.name.localeCompare(name) == 1);
if (idx > -1) {
QuickReplySet.list.splice(idx, 0, set);
} else {
QuickReplySet.list.push(set);
}
}
await set.save();
this.settingsUi.rerender();
return set;
}
/**
* Update an existing quick reply set.
*
* @param {string} name name of the existing quick reply set
* @param {object} [props]
* @param {boolean} [props.disableSend] whether or not to send the quick replies or put the message or slash command into the char input box
* @param {boolean} [props.placeBeforeInput] whether or not to place the quick reply contents before the existing user input
* @param {boolean} [props.injectInput] whether or not to automatically inject the user input at the end of the quick reply
* @returns {Promise<QuickReplySet>} the altered quick reply set
*/
async updateSet(name, {
disableSend,
placeBeforeInput,
injectInput,
} = {}) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
set.disableSend = disableSend ?? false;
set.placeBeforeInput = placeBeforeInput ?? false;
set.injectInput = injectInput ?? false;
await set.save();
this.settingsUi.rerender();
return set;
}
/**
* Delete an existing quick reply set.
*
* @param {string} name name of the existing quick reply set
*/
async deleteSet(name) {
const set = this.getSetByName(name);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
await set.delete();
this.settingsUi.rerender();
}
/**
* Gets a list of all quick reply sets.
*
* @returns array with the names of all quick reply sets
*/
listSets() {
return QuickReplySet.list.map(it=>it.name);
}
/**
* Gets a list of all globally active quick reply sets.
*
* @returns array with the names of all quick reply sets
*/
listGlobalSets() {
return this.settings.config.setList.map(it=>it.set.name);
}
/**
* Gets a list of all quick reply sets activated by the current chat.
*
* @returns array with the names of all quick reply sets
*/
listChatSets() {
return this.settings.chatConfig?.setList?.flatMap(it=>it.set.name) ?? [];
}
/**
* Gets a list of all quick replies in the quick reply set.
*
* @param {string} setName name of the existing quick reply set
* @returns array with the labels of this set's quick replies
*/
listQuickReplies(setName) {
const set = this.getSetByName(setName);
if (!set) {
throw new Error(`No quick reply set with name "${name}" found.`);
}
return set.qrList.map(it=>it.label);
}
/**
* Gets a list of all Automation IDs used by quick replies.
*
* @returns {String[]} array with all automation IDs used by quick replies
*/
listAutomationIds() {
return this
.listSets()
.flatMap(it => ({ set: it, qrs: this.listQuickReplies(it) }))
.map(it => it.qrs?.map(qr => this.getQrByLabel(it.set, qr)?.automationId))
.flat()
.filter(Boolean)
.filter(onlyUnique)
.map(String);
}
}

View File

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

View File

@@ -0,0 +1,86 @@
<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--editor">
<div class="qr--head">
<div class="qr--title" data-i18n="Edit Quick Replies">Edit Quick Replies</div>
<div class="qr--actions">
<select id="qr--set" class="text_pole"></select>
<div class="qr--add menu_button menu_button_icon fa-solid fa-pencil" id="qr--set-rename" title="Rename quick reply set"></div>
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-new" title="Create new quick reply set"></div>
<div class="qr--add menu_button menu_button_icon fa-solid fa-file-import" id="qr--set-import" title="Import quick reply set"></div>
<input type="file" id="qr--set-importFile" accept=".json" hidden>
<div class="qr--add menu_button menu_button_icon fa-solid fa-file-export" id="qr--set-export" title="Export quick reply set"></div>
<div class="qr-add menu_button menu_button_icon fa-solid fa-paste" id="qr--set-duplicate" title="Duplicate quick reply set"></div>
<div class="qr--del menu_button menu_button_icon fa-solid fa-trash redWarningBG" id="qr--set-delete" title="Delete quick reply set"></div>
</div>
</div>
<div id="qr--set-settings">
<label class="flex-container">
<input type="checkbox" id="qr--disableSend"> <span data-i18n="Disable Send (Insert Into Input Field)">Disable send (insert into input field)</span>
</label>
<label class="flex-container">
<input type="checkbox" id="qr--placeBeforeInput"> <span data-i18n="Place Quick Reply Before Input">Place quick reply before input</span>
</label>
<label class="flex-container" id="qr--injectInputContainer">
<input type="checkbox" id="qr--injectInput"> <span><span data-i18n="Inject user input automatically">Inject user input automatically</span> <small><span data-i18n="(if disabled, use ">(if disabled, use</span><code>{{input}}</code> <span data-i18n="macro for manual injection)">macro for manual injection)</span></small></span>
</label>
<div class="flex-container alignItemsCenter">
<toolcool-color-picker id="qr--color"></toolcool-color-picker>
<div class="menu_button" id="qr--colorClear">Clear</div>
<span data-i18n="Color">Color</span>
</div>
<label class="flex-container" id="qr--onlyBorderColorContainer">
<input type="checkbox" id="qr--onlyBorderColor"> <span data-i18n="Only apply color as accent">Only apply color as accent</span>
</label>
</div>
<div id="qr--set-qrList" class="qr--qrList"></div>
<div class="qr--set-qrListActions">
<div class="qr--add menu_button menu_button_icon fa-solid fa-plus" id="qr--set-add" title="Add quick reply"></div>
<div class="qr--paste menu_button menu_button_icon fa-solid fa-paste" id="qr--set-paste" title="Paste quick reply from clipboard"></div>
<div class="qr--import menu_button menu_button_icon fa-solid fa-file-import" id="qr--set-importQr" title="Import quick reply from file"></div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,244 @@
import { chat, chat_metadata, eventSource, event_types, getRequestHeaders } 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';
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 {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.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);
} 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 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 ?? [])]
.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());
window['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 onChatChanged = async (chatIdx) => {
log('CHAT_CHANGED', chatIdx);
if (chatIdx) {
settings.chatConfig = QuickReplyConfig.from(chat_metadata.quickReply ?? {});
} else {
settings.chatConfig = null;
}
manager.rerender();
buttons.refresh();
await autoExec.handleChatChanged();
};
eventSource.on(event_types.CHAT_CHANGED, (...args)=>executeIfReadyElseQueue(onChatChanged, args));
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));

View File

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

View File

@@ -0,0 +1,108 @@
import { warn } from '../index.js';
import { QuickReply } from './QuickReply.js';
import { QuickReplySettings } from './QuickReplySettings.js';
export class AutoExecuteHandler {
/** @type {QuickReplySettings} */ settings;
/** @type {Boolean[]}*/ preventAutoExecuteStack = [];
constructor(/** @type {QuickReplySettings} */settings) {
this.settings = settings;
}
checkExecute() {
return this.settings.isEnabled && !this.preventAutoExecuteStack.slice(-1)[0];
}
async performAutoExecute(/** @type {QuickReply[]} */qrList) {
for (const qr of qrList) {
this.preventAutoExecuteStack.push(qr.preventAutoExecute);
try {
await qr.execute({ isAutoExecute:true });
} catch (ex) {
warn(ex);
} finally {
this.preventAutoExecuteStack.pop();
}
}
}
async handleStartup() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnStartup)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnStartup))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
async handleUser() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnUser)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnUser))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
async handleAi() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnAi)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnAi))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
async handleChatChanged() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnChatChange)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnChatChange))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
async handleGroupMemberDraft() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnGroupMemberDraft)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnGroupMemberDraft))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
async handleNewChat() {
if (!this.checkExecute()) return;
const qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.executeOnNewChat)).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.executeOnNewChat))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
/**
* @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 qrList = [
...this.settings.config.setList.map(link=>link.set.qrList.filter(qr=>qr.automationId && automationIds.includes(qr.automationId))).flat(),
...(this.settings.chatConfig?.setList?.map(link=>link.set.qrList.filter(qr=>qr.automationId && automationIds.includes(qr.automationId)))?.flat() ?? []),
];
await this.performAutoExecute(qrList);
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,403 @@
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 = [];
static from(props) {
props.qrList = []; //props.qrList?.map(it=>QuickReply.from(it));
const instance = Object.assign(new this(), props);
// instance.init();
return instance;
}
/**
* @param {string} name - name of the QuickReplySet
*/
static get(name) {
return this.list.find(it=>it.name == name);
}
/**@type {string}*/ name;
/**@type {boolean}*/ disableSend = false;
/**@type {boolean}*/ placeBeforeInput = false;
/**@type {boolean}*/ injectInput = false;
/**@type {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) {
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') {
(dlg.dom ?? dlg.dlg).classList.remove('qr--isCopy');
return;
}
});
sel.addEventListener('keydown', (evt)=>{
if (evt.key == 'Shift') {
(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();
});
(dlg.ok ?? dlg.okButton).insertAdjacentElement('afterend', copyBtn);
}
const prom = dlg.show();
sel.focus();
await prom;
if (dlg.result == POPUP_RESULT.AFFIRMATIVE) {
const qrs = QuickReplySet.list.find(it=>it.name == sel.value);
qrs.addQuickReply(qr.toJSON());
if (!isCopy) {
qr.delete();
}
}
};
}
removeQuickReply(qr) {
this.qrList.splice(this.qrList.indexOf(qr), 1);
this.save();
}
toJSON() {
return {
version: 2,
name: this.name,
disableSend: this.disableSend,
placeBeforeInput: this.placeBeforeInput,
injectInput: this.injectInput,
color: this.color,
onlyBorderColor: this.onlyBorderColor,
qrList: this.qrList,
idIndex: this.idIndex,
};
}
async performSave() {
const response = await fetch('/api/quick-replies/save', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(this),
});
if (response.ok) {
this.rerender();
} else {
warn(`Failed to save Quick Reply Set: ${this.name}`);
console.error('QR could not be saved', response);
}
}
async delete() {
const response = await fetch('/api/quick-replies/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(this),
});
if (response.ok) {
this.unrender();
const idx = QuickReplySet.list.indexOf(this);
if (idx > -1) {
QuickReplySet.list.splice(idx, 1);
this.isDeleted = true;
} else {
warn(`Deleted Quick Reply Set was not found in the list of sets: ${this.name}`);
}
} else {
warn(`Failed to delete Quick Reply Set: ${this.name}`);
}
}
}

View File

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

View File

@@ -0,0 +1,87 @@
import { chat_metadata, saveChatDebounced, saveSettingsDebounced } from '../../../../script.js';
import { extension_settings } from '../../../extensions.js';
import { QuickReplyConfig } from './QuickReplyConfig.js';
export class QuickReplySettings {
static from(props) {
props.config = QuickReplyConfig.from(props.config);
const instance = Object.assign(new this(), props);
instance.init();
return instance;
}
/**@type {Boolean}*/ isEnabled = false;
/**@type {Boolean}*/ isCombined = false;
/**@type {Boolean}*/ isPopout = false;
/**@type {Boolean}*/ showPopoutButton = true;
/**@type {QuickReplyConfig}*/ config;
/**@type {QuickReplyConfig}*/ _chatConfig;
get chatConfig() {
return this._chatConfig;
}
set chatConfig(value) {
if (this._chatConfig != value) {
this.unhookConfig(this._chatConfig);
this._chatConfig = value;
this.hookConfig(this._chatConfig);
}
}
/**@type {Function}*/ onSave;
/**@type {Function}*/ onRequestEditSet;
init() {
this.hookConfig(this.config);
this.hookConfig(this.chatConfig);
}
hookConfig(config) {
if (config) {
config.onUpdate = ()=>this.save();
config.onRequestEditSet = (qrs)=>this.requestEditSet(qrs);
}
}
unhookConfig(config) {
if (config) {
config.onUpdate = null;
config.onRequestEditSet = null;
}
}
save() {
extension_settings.quickReplyV2 = this.toJSON();
saveSettingsDebounced();
if (this.chatConfig) {
chat_metadata.quickReply = this.chatConfig.toJSON();
saveChatDebounced();
}
if (this.onSave) {
this.onSave();
}
}
requestEditSet(qrs) {
if (this.onRequestEditSet) {
this.onRequestEditSet(qrs);
}
}
toJSON() {
return {
isEnabled: this.isEnabled,
isCombined: this.isCombined,
isPopout: this.isPopout,
showPopoutButton: this.showPopoutButton,
config: this.config,
};
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,503 @@
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 {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);
}
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');
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;
this.color.color = qrs.color;
return;
}
qrs.color = evt.detail.rgb;
qrs.save();
this.currentQrSet.updateColor();
});
this.dom.querySelector('#qr--colorClear').addEventListener('click', (evt)=>{
const qrs = this.currentQrSet;
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;
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.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);
}
}
}
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.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();
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();
}
} 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();
}
}
}
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();
}
} 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();
}
}
} 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(JSON.parse(JSON.stringify(this.currentQrSet)));
newQrSet.name = newName;
newQrSet.qrList = this.currentQrSet.qrList.map(qr => QuickReply.from(JSON.parse(JSON.stringify(qr))));
newQrSet.addQuickReply();
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();
}
}
selectQrSet(qrs) {
this.currentSet.value = qrs.name;
this.onQrSetChange();
}
}

View File

@@ -0,0 +1,130 @@
import { QuickReply } from '../../QuickReply.js';
import { QuickReplySet } from '../../QuickReplySet.js';
import { MenuHeader } from './MenuHeader.js';
import { MenuItem } from './MenuItem.js';
export class ContextMenu {
/**@type {MenuItem[]}*/ itemList = [];
/**@type {Boolean}*/ isActive = false;
/**@type {HTMLElement}*/ root;
/**@type {HTMLElement}*/ menu;
constructor(/**@type {QuickReply}*/qr) {
// this.itemList = items;
this.itemList = this.build(qr).children;
this.itemList.forEach(item => {
item.onExpand = () => {
this.itemList.filter(it => it !== item)
.forEach(it => it.collapse());
};
});
}
/**
* @param {QuickReply} qr
* @param {String} chainedMessage
* @param {QuickReplySet[]} hierarchy
* @param {String[]} labelHierarchy
*/
build(qr, chainedMessage = null, hierarchy = [], labelHierarchy = []) {
const tree = {
icon: qr.icon,
showLabel: qr.showLabel,
label: qr.label,
title: qr.title,
message: (chainedMessage && qr.message ? `${chainedMessage} | ` : '') + qr.message,
children: [],
};
qr.contextList.forEach((cl) => {
if (!cl.set) return;
if (!hierarchy.includes(cl.set)) {
const nextHierarchy = [...hierarchy, cl.set];
const nextLabelHierarchy = [...labelHierarchy, tree.label];
tree.children.push(new MenuHeader(cl.set.name));
// If the Quick Reply's own set is added as a context menu,
// show only the sub-QRs that are Invisible but have an icon
// intent: allow a QR set to be assigned to one of its own QR buttons for a "burger" menu
// with "UI" QRs either in the bar or in the menu, and "library function" QRs still hidden.
// - QRs already visible on the bar are filtered out,
// - hidden QRs without an icon are filtered out,
// - hidden QRs **with an icon** are shown in the menu
// so everybody is happy
const qrsOwnSetAddedAsContextMenu = cl.set.qrList.includes(qr);
const visible = (subQr) => {
return qrsOwnSetAddedAsContextMenu
? subQr.isHidden && !!subQr.icon // yes .isHidden gets inverted here
: !subQr.isHidden;
};
cl.set.qrList.filter(visible).forEach(subQr => {
const subTree = this.build(subQr, cl.isChained ? tree.message : null, nextHierarchy, nextLabelHierarchy);
tree.children.push(new MenuItem(
subTree.icon,
subTree.showLabel,
subTree.label,
subTree.title,
subTree.message,
(evt) => {
evt.stopPropagation();
const finalQr = Object.assign(new QuickReply(), subQr);
finalQr.message = subTree.message.replace(/%%parent(-\d+)?%%/g, (_, index) => {
return nextLabelHierarchy.slice(parseInt(index ?? '-1'))[0];
});
cl.set.execute(finalQr);
},
subTree.children,
));
});
}
});
return tree;
}
render() {
if (!this.root) {
const blocker = document.createElement('div'); {
this.root = blocker;
blocker.classList.add('ctx-blocker');
blocker.addEventListener('click', () => this.hide());
const menu = document.createElement('ul'); {
this.menu = menu;
menu.classList.add('list-group');
menu.classList.add('ctx-menu');
this.itemList.forEach(it => menu.append(it.render()));
blocker.append(menu);
}
}
}
return this.root;
}
show({ clientX, clientY }) {
if (this.isActive) return;
this.isActive = true;
this.render();
this.menu.style.bottom = `${window.innerHeight - clientY}px`;
this.menu.style.left = `${clientX}px`;
document.body.append(this.root);
}
hide() {
if (this.root) {
this.root.remove();
}
this.isActive = false;
}
toggle(/**@type {PointerEvent}*/evt) {
if (this.isActive) {
this.hide();
} else {
this.show(evt);
}
}
}

View File

@@ -0,0 +1,20 @@
import { MenuItem } from './MenuItem.js';
export class MenuHeader extends MenuItem {
constructor(/**@type {String}*/label) {
super(null, null, label, null, null, null, []);
}
render() {
if (!this.root) {
const item = document.createElement('li'); {
this.root = item;
item.classList.add('list-group-item');
item.classList.add('ctx-header');
item.append(this.label);
}
}
return this.root;
}
}

View File

@@ -0,0 +1,107 @@
import { SubMenu } from './SubMenu.js';
export class MenuItem {
/**@type {string}*/ icon;
/**@type {boolean}*/ showLabel;
/**@type {string}*/ label;
/**@type {string}*/ title;
/**@type {object}*/ value;
/**@type {function}*/ callback;
/**@type {MenuItem[]}*/ childList = [];
/**@type {SubMenu}*/ subMenu;
/**@type {HTMLElement}*/ root;
/**@type {function}*/ onExpand;
/**
*
* @param {?string} icon
* @param {?boolean} showLabel
* @param {string} label
* @param {?string} title Tooltip
* @param {object} value
* @param {function} callback
* @param {MenuItem[]} children
*/
constructor(icon, showLabel, label, title, value, callback, children = []) {
this.icon = icon;
this.showLabel = showLabel;
this.label = label;
this.title = title;
this.value = value;
this.callback = callback;
this.childList = children;
}
render() {
if (!this.root) {
const item = document.createElement('li'); {
this.root = item;
item.classList.add('list-group-item');
item.classList.add('ctx-item');
// if a title/tooltip is set, add it, otherwise use the QR content
// same as for the main QR list
item.title = this.title || this.value;
if (this.callback) {
item.addEventListener('click', (evt) => this.callback(evt, this));
}
const icon = document.createElement('div'); {
icon.classList.add('qr--button-icon');
icon.classList.add('fa-solid');
if (!this.icon) icon.classList.add('qr--hidden');
else icon.classList.add(this.icon);
item.append(icon);
}
const lbl = document.createElement('div'); {
lbl.classList.add('qr--button-label');
if (this.icon && !this.showLabel) lbl.classList.add('qr--hidden');
lbl.textContent = this.label;
item.append(lbl);
}
if (this.childList.length > 0) {
item.classList.add('ctx-has-children');
const sub = new SubMenu(this.childList);
this.subMenu = sub;
const trigger = document.createElement('div'); {
trigger.classList.add('ctx-expander');
trigger.textContent = '⋮';
trigger.addEventListener('click', (evt) => {
evt.stopPropagation();
this.toggle();
});
item.append(trigger);
}
item.addEventListener('mouseover', () => sub.show(item));
item.addEventListener('mouseleave', () => sub.hide());
}
}
}
return this.root;
}
expand() {
this.subMenu?.show(this.root);
if (this.onExpand) {
this.onExpand();
}
}
collapse() {
this.subMenu?.hide();
}
toggle() {
if (this.subMenu.isActive) {
this.expand();
} else {
this.collapse();
}
}
}

View File

@@ -0,0 +1,66 @@
/**
* @typedef {import('./MenuItem.js').MenuItem} MenuItem
*/
export class SubMenu {
/**@type {MenuItem[]}*/ itemList = [];
/**@type {Boolean}*/ isActive = false;
/**@type {HTMLElement}*/ root;
constructor(/**@type {MenuItem[]}*/items) {
this.itemList = items;
}
render() {
if (!this.root) {
const menu = document.createElement('ul'); {
this.root = menu;
menu.classList.add('list-group');
menu.classList.add('ctx-menu');
menu.classList.add('ctx-sub-menu');
this.itemList.forEach(it => menu.append(it.render()));
}
}
return this.root;
}
show(/**@type {HTMLElement}*/parent) {
if (this.isActive) return;
this.isActive = true;
this.render();
parent.append(this.root);
requestAnimationFrame(() => {
const rect = this.root.getBoundingClientRect();
console.log(window.innerHeight, rect);
if (rect.bottom > window.innerHeight - 5) {
this.root.style.top = `${window.innerHeight - 5 - rect.bottom}px`;
}
if (rect.right > window.innerWidth - 5) {
this.root.style.left = 'unset';
this.root.style.right = '100%';
}
});
}
hide() {
if (this.root) {
this.root.remove();
this.root.style.top = '';
this.root.style.left = '';
}
this.isActive = false;
}
toggle(/**@type {HTMLElement}*/parent) {
if (this.isActive) {
this.hide();
} else {
this.show(parent);
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
<div class="regex_settings">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b data-i18n="ext_regex_title">
Regex
</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<div class="flex-container">
<div id="open_regex_editor" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_new_global_script_desc" title="New global regex script">
<i class="fa-solid fa-pen-to-square"></i>
<small data-i18n="ext_regex_new_global_script">+ Global</small>
</div>
<div id="open_scoped_editor" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_new_scoped_script_desc" title="New scoped regex script">
<i class="fa-solid fa-address-card"></i>
<small data-i18n="ext_regex_new_scoped_script">+ Scoped</small>
</div>
<div id="import_regex" class="menu_button menu_button_icon">
<i class="fa-solid fa-file-import"></i>
<small data-i18n="ext_regex_import_script">Import</small>
</div>
<input type="file" id="import_regex_file" hidden accept="*.json" multiple />
</div>
<hr />
<div id="global_scripts_block" class="padding5">
<div>
<strong data-i18n="ext_regex_global_scripts">Global Scripts</strong>
</div>
<small data-i18n="ext_regex_global_scripts_desc">
Available for all characters. Saved to local settings.
</small>
<div id="saved_regex_scripts" class="flex-container regex-script-container flexFlowColumn"></div>
</div>
<hr />
<div id="scoped_scripts_block" class="padding5">
<div class="flex-container alignItemsBaseline">
<strong class="flex1" data-i18n="ext_regex_scoped_scripts">Scoped Scripts</strong>
<label id="toggle_scoped_regex" class="checkbox flex-container" for="regex_scoped_toggle">
<input type="checkbox" id="regex_scoped_toggle" class="enable_scoped" />
<span class="regex-toggle-on fa-solid fa-toggle-on fa-lg" data-i18n="[title]ext_regex_disallow_scoped" title="Disallow using scoped regex"></span>
<span class="regex-toggle-off fa-solid fa-toggle-off fa-lg" data-i18n="[title]ext_regex_allow_scoped" title="Allow using scoped regex"></span>
</label>
</div>
<small data-i18n="ext_regex_scoped_scripts_desc">
Only available for this character. Saved to the card data.
</small>
<div id="saved_scoped_scripts" class="flex-container regex-script-container flexFlowColumn"></div>
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,205 @@
import { characters, substituteParams, substituteParamsExtended, this_chid } from '../../../script.js';
import { extension_settings } from '../../extensions.js';
import { regexFromString } from '../../utils.js';
export {
regex_placement,
getRegexedString,
runRegexScript,
};
/**
* @enum {number} Where the regex script should be applied
*/
const regex_placement = {
/**
* @deprecated MD Display is deprecated. Do not use.
*/
MD_DISPLAY: 0,
USER_INPUT: 1,
AI_OUTPUT: 2,
SLASH_COMMAND: 3,
// 4 - sendAs (legacy)
WORLD_INFO: 5,
REASONING: 6,
};
export const substitute_find_regex = {
NONE: 0,
RAW: 1,
ESCAPED: 2,
};
function sanitizeRegexMacro(x) {
return (x && typeof x === 'string') ?
x.replaceAll(/[\n\r\t\v\f\0.^$*+?{}[\]\\/|()]/gs, function (s) {
switch (s) {
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
case '\v':
return '\\v';
case '\f':
return '\\f';
case '\0':
return '\\0';
default:
return '\\' + s;
}
}) : x;
}
function getScopedRegex() {
const isAllowed = extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar);
if (!isAllowed) {
return [];
}
const scripts = characters[this_chid]?.data?.extensions?.regex_scripts;
if (!Array.isArray(scripts)) {
return [];
}
return scripts;
}
/**
* Parent function to fetch a regexed version of a raw string
* @param {string} rawString The raw string to be regexed
* @param {regex_placement} placement The placement of the string
* @param {RegexParams} params The parameters to use for the regex script
* @returns {string} The regexed string
* @typedef {{characterOverride?: string, isMarkdown?: boolean, isPrompt?: boolean, isEdit?: boolean, depth?: number }} RegexParams The parameters to use for the regex script
*/
function getRegexedString(rawString, placement, { characterOverride, isMarkdown, isPrompt, isEdit, depth } = {}) {
// WTF have you passed me?
if (typeof rawString !== 'string') {
console.warn('getRegexedString: rawString is not a string. Returning empty string.');
return '';
}
let finalString = rawString;
if (extension_settings.disabledExtensions.includes('regex') || !rawString || placement === undefined) {
return finalString;
}
const allRegex = [...(extension_settings.regex ?? []), ...(getScopedRegex() ?? [])];
allRegex.forEach((script) => {
if (
// Script applies to Markdown and input is Markdown
(script.markdownOnly && isMarkdown) ||
// Script applies to Generate and input is Generate
(script.promptOnly && isPrompt) ||
// Script applies to all cases when neither "only"s are true, but there's no need to do it when `isMarkdown`, the as source (chat history) should already be changed beforehand
(!script.markdownOnly && !script.promptOnly && !isMarkdown && !isPrompt)
) {
if (isEdit && !script.runOnEdit) {
console.debug(`getRegexedString: Skipping script ${script.scriptName} because it does not run on edit`);
return;
}
// Check if the depth is within the min/max depth
if (typeof depth === 'number') {
if (!isNaN(script.minDepth) && script.minDepth !== null && script.minDepth >= -1 && depth < script.minDepth) {
console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is less than minDepth ${script.minDepth}`);
return;
}
if (!isNaN(script.maxDepth) && script.maxDepth !== null && script.maxDepth >= 0 && depth > script.maxDepth) {
console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is greater than maxDepth ${script.maxDepth}`);
return;
}
}
if (script.placement.includes(placement)) {
finalString = runRegexScript(script, finalString, { characterOverride });
}
}
});
return finalString;
}
/**
* Runs the provided regex script on the given string
* @param {import('./index.js').RegexScript} regexScript The regex script to run
* @param {string} rawString The string to run the regex script on
* @param {RegexScriptParams} params The parameters to use for the regex script
* @returns {string} The new string
* @typedef {{characterOverride?: string}} RegexScriptParams The parameters to use for the regex script
*/
function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
let newString = rawString;
if (!regexScript || !!(regexScript.disabled) || !regexScript?.findRegex || !rawString) {
return newString;
}
const getRegexString = () => {
switch (Number(regexScript.substituteRegex)) {
case substitute_find_regex.NONE:
return regexScript.findRegex;
case substitute_find_regex.RAW:
return substituteParamsExtended(regexScript.findRegex);
case substitute_find_regex.ESCAPED:
return substituteParamsExtended(regexScript.findRegex, {}, sanitizeRegexMacro);
default:
console.warn(`runRegexScript: Unknown substituteRegex value ${regexScript.substituteRegex}. Using raw regex.`);
return regexScript.findRegex;
}
};
const regexString = getRegexString();
const findRegex = regexFromString(regexString);
// The user skill issued. Return with nothing.
if (!findRegex) {
return newString;
}
// Run replacement. Currently does not support the Overlay strategy
newString = rawString.replace(findRegex, function (match) {
const args = [...arguments];
const replaceString = regexScript.replaceString.replace(/{{match}}/gi, '$0');
const replaceWithGroups = replaceString.replaceAll(/\$(\d+)/g, (_, num) => {
// Get a full match or a capture group
const match = args[Number(num)];
// No match found - return the empty string
if (!match) {
return '';
}
// Remove trim strings from the match
const filteredMatch = filterString(match, regexScript.trimStrings, { characterOverride });
// TODO: Handle overlay here
return filteredMatch;
});
// Substitute at the end
return substituteParams(replaceWithGroups);
});
return newString;
}
/**
* Filters anything to trim from the regex match
* @param {string} rawString The raw string to filter
* @param {string[]} trimStrings The strings to trim
* @param {RegexScriptParams} params The parameters to use for the regex filter
* @returns {string} The filtered string
*/
function filterString(rawString, trimStrings, { characterOverride } = {}) {
let finalString = rawString;
trimStrings.forEach((trimString) => {
const subTrimString = substituteParams(trimString, undefined, characterOverride);
finalString = finalString.replaceAll(subTrimString, '');
});
return finalString;
}

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