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

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

View File

@@ -0,0 +1,888 @@
'use strict';
import {
characterGroupOverlay,
characters,
event_types,
eventSource,
getCharacters,
getRequestHeaders,
buildAvatarList,
characterToEntity,
printCharactersDebounced,
deleteCharacter,
} from '../script.js';
import { favsToHotswap } from './RossAscends-mods.js';
import { hideLoader, showLoader } from './loader.js';
import { convertCharacterToPersona } from './personas.js';
import { callGenericPopup, POPUP_TYPE } from './popup.js';
import { createTagInput, getTagKeyForEntity, getTagsList, printTagList, tag_map, compareTagsForSort, removeTagFromMap, importTags, tag_import_setting } from './tags.js';
/**
* Static object representing the actions of the
* character context menu override.
*/
class CharacterContextMenu {
/**
* Tag one or more characters,
* opens a popup.
*
* @param {Array<number>} selectedCharacters
*/
static tag = (selectedCharacters) => {
characterGroupOverlay.bulkTagPopupHandler.show(selectedCharacters);
};
/**
* Duplicate one or more characters
*
* @param {number} characterId
* @returns {Promise<any>}
*/
static duplicate = async (characterId) => {
const character = CharacterContextMenu.#getCharacter(characterId);
const body = { avatar_url: character.avatar };
const result = await fetch('/api/characters/duplicate', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(body),
});
if (!result.ok) {
throw new Error('Character not duplicated');
}
const data = await result.json();
await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path });
};
/**
* Favorite a character
* and highlight it.
*
* @param {number} characterId
* @returns {Promise<void>}
*/
static favorite = async (characterId) => {
const character = CharacterContextMenu.#getCharacter(characterId);
const newFavState = !character.data.extensions.fav;
const data = {
name: character.name,
avatar: character.avatar,
data: {
extensions: {
fav: newFavState,
},
},
fav: newFavState,
};
const mergeResponse = await fetch('/api/characters/merge-attributes', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(data),
});
if (!mergeResponse.ok) {
mergeResponse.json().then(json => toastr.error(`Character not saved. Error: ${json.message}. Field: ${json.error}`));
}
const element = document.getElementById(`CharID${characterId}`);
element.classList.toggle('is_fav');
};
/**
* Convert one or more characters to persona,
* may open a popup for one or more characters.
*
* @param {number} characterId
* @returns {Promise<void>}
*/
static persona = async (characterId) => void(await convertCharacterToPersona(characterId));
/**
* Delete one or more characters,
* opens a popup.
*
* @param {string|string[]} characterKey
* @param {boolean} [deleteChats]
* @returns {Promise<void>}
*/
static delete = async (characterKey, deleteChats = false) => {
await deleteCharacter(characterKey, { deleteChats: deleteChats });
};
static #getCharacter = (characterId) => characters[characterId] ?? null;
/**
* Show the context menu at the given position
*
* @param positionX
* @param positionY
*/
static show = (positionX, positionY) => {
let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
contextMenu.style.left = `${positionX}px`;
contextMenu.style.top = `${positionY}px`;
document.getElementById(BulkEditOverlay.contextMenuId).classList.remove('hidden');
// Adjust position if context menu is outside of viewport
const boundingRect = contextMenu.getBoundingClientRect();
if (boundingRect.right > window.innerWidth) {
contextMenu.style.left = `${positionX - (boundingRect.right - window.innerWidth)}px`;
}
if (boundingRect.bottom > window.innerHeight) {
contextMenu.style.top = `${positionY - (boundingRect.bottom - window.innerHeight)}px`;
}
};
/**
* Hide the context menu
*/
static hide = () => document.getElementById(BulkEditOverlay.contextMenuId).classList.add('hidden');
/**
* Sets up the context menu for the given overlay
*
* @param characterGroupOverlay
*/
constructor(characterGroupOverlay) {
const contextMenuItems = [
{ id: 'character_context_menu_favorite', callback: characterGroupOverlay.handleContextMenuFavorite },
{ id: 'character_context_menu_duplicate', callback: characterGroupOverlay.handleContextMenuDuplicate },
{ id: 'character_context_menu_delete', callback: characterGroupOverlay.handleContextMenuDelete },
{ id: 'character_context_menu_persona', callback: characterGroupOverlay.handleContextMenuPersona },
{ id: 'character_context_menu_tag', callback: characterGroupOverlay.handleContextMenuTag },
];
contextMenuItems.forEach(contextMenuItem => document.getElementById(contextMenuItem.id).addEventListener('click', contextMenuItem.callback));
}
}
/**
* Represents a tag control not bound to a single character
*/
class BulkTagPopupHandler {
/**
* The characters for this popup
* @type {number[]}
*/
characterIds;
/**
* A storage of the current mutual tags, as calculated by getMutualTags()
* @type {object[]}
*/
currentMutualTags;
/**
* Sets up the bulk popup menu handler for the given overlay.
*
* Characters can be passed in with the show() call.
*/
constructor() { }
/**
* Gets the HTML as a string that is going to be the popup for the bulk tag edit
*
* @returns String containing the html for the popup
*/
#getHtml = () => {
const characterData = JSON.stringify({ characterIds: this.characterIds });
return `<div id="bulk_tag_shadow_popup">
<div id="bulk_tag_popup" class="wider_dialogue_popup">
<div id="bulk_tag_popup_holder">
<h3 class="marginBot5">Modify tags of ${this.characterIds.length} characters</h3>
<small class="bulk_tags_desc m-b-1">Add or remove the mutual tags of all selected characters. Import all or existing tags for all selected characters.</small>
<div id="bulk_tags_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline"></div>
<br>
<div id="bulk_tags_div" class="marginBot5" data-characters='${characterData}'>
<div class="tag_controls">
<input id="bulkTagInput" class="text_pole tag_input wide100p margin0" data-i18n="[placeholder]Search / Create Tags" placeholder="Search / Create tags" maxlength="25" />
<div class="tags_view menu_button fa-solid fa-tags" title="View all tags" data-i18n="[title]View all tags"></div>
</div>
<div id="bulkTagList" class="m-t-1 tags"></div>
</div>
<div id="dialogue_popup_controls" class="m-t-1">
<div id="bulk_tag_popup_reset" class="menu_button" title="Remove all tags from the selected characters" data-i18n="[title]Remove all tags from the selected characters">
<i class="fa-solid fa-trash-can margin-right-10px"></i>
All
</div>
<div id="bulk_tag_popup_remove_mutual" class="menu_button" title="Remove all mutual tags from the selected characters" data-i18n="[title]Remove all mutual tags from the selected characters">
<i class="fa-solid fa-trash-can margin-right-10px"></i>
Mutual
</div>
<div id="bulk_tag_popup_import_all_tags" class="menu_button" title="Import all tags from selected characters" data-i18n="[title]Import all tags from selected characters">
Import All
</div>
<div id="bulk_tag_popup_import_existing_tags" class="menu_button" title="Import existing tags from selected characters" data-i18n="[title]Import existing tags from selected characters">
Import Existing
</div>
<div id="bulk_tag_popup_cancel" class="menu_button" data-i18n="Cancel">Close</div>
</div>
</div>
</div>
</div>`;
};
/**
* Append and show the tag control
*
* @param {number[]} characterIds - The characters that are shown inside the popup
*/
show(characterIds) {
// shallow copy character ids persistently into this tooltip
this.characterIds = characterIds.slice();
if (this.characterIds.length == 0) {
console.log('No characters selected for bulk edit tags.');
return;
}
document.body.insertAdjacentHTML('beforeend', this.#getHtml());
const entities = this.characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined);
buildAvatarList($('#bulk_tags_avatars_block'), entities);
// Print the tag list with all mutuable tags, marking them as removable. That is the initial fill
printTagList($('#bulkTagList'), { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
// Tag input with resolvable list for the mutual tags to get redrawn, so that newly added tags get sorted correctly
createTagInput('#bulkTagInput', '#bulkTagList', { tags: () => this.getMutualTags(), tagOptions: { removable: true } });
document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this));
document.querySelector('#bulk_tag_popup_remove_mutual').addEventListener('click', this.removeMutual.bind(this));
document.querySelector('#bulk_tag_popup_cancel').addEventListener('click', this.hide.bind(this));
document.querySelector('#bulk_tag_popup_import_all_tags').addEventListener('click', this.importAllTags.bind(this));
document.querySelector('#bulk_tag_popup_import_existing_tags').addEventListener('click', this.importExistingTags.bind(this));
}
/**
* Import existing tags for all selected characters
*/
async importExistingTags() {
for (const characterId of this.characterIds) {
await importTags(characters[characterId], { importSetting: tag_import_setting.ONLY_EXISTING });
}
$('#bulkTagList').empty();
}
/**
* Import all tags for all selected characters
*/
async importAllTags() {
for (const characterId of this.characterIds) {
await importTags(characters[characterId], { importSetting: tag_import_setting.ALL });
}
$('#bulkTagList').empty();
}
/**
* Builds a list of all tags that the provided characters have in common.
*
* @returns {Array<object>} A list of mutual tags
*/
getMutualTags() {
if (this.characterIds.length == 0) {
return [];
}
if (this.characterIds.length === 1) {
// Just use tags of the single character
return getTagsList(getTagKeyForEntity(this.characterIds[0]));
}
// Find mutual tags for multiple characters
const allTags = this.characterIds.map(cid => getTagsList(getTagKeyForEntity(cid)));
const mutualTags = allTags.reduce((mutual, characterTags) =>
mutual.filter(tag => characterTags.some(cTag => cTag.id === tag.id)),
);
this.currentMutualTags = mutualTags.sort(compareTagsForSort);
return this.currentMutualTags;
}
/**
* Hide and remove the tag control
*/
hide() {
let popupElement = document.querySelector('#bulk_tag_shadow_popup');
if (popupElement) {
document.body.removeChild(popupElement);
}
// No need to redraw here, all tags actions were redrawn when they happened
}
/**
* Empty the tag map for the given characters
*/
resetTags() {
for (const characterId of this.characterIds) {
const key = getTagKeyForEntity(characterId);
if (key) tag_map[key] = [];
}
$('#bulkTagList').empty();
printCharactersDebounced();
}
/**
* Remove the mutual tags for all given characters
*/
removeMutual() {
const mutualTags = this.getMutualTags();
for (const characterId of this.characterIds) {
for (const tag of mutualTags) {
removeTagFromMap(tag.id, characterId.toString());
}
}
$('#bulkTagList').empty();
printCharactersDebounced();
}
}
class BulkEditOverlayState {
/**
*
* @type {number}
*/
static browse = 0;
/**
*
* @type {number}
*/
static select = 1;
}
/**
* Implement a SingletonPattern, allowing access to the group overlay instance
* from everywhere via (new CharacterGroupOverlay())
*
* @type {Readonly<BulkEditOverlay>}
*/
let bulkEditOverlayInstance = null;
class BulkEditOverlay {
static containerId = 'rm_print_characters_block';
static contextMenuId = 'character_context_menu';
static characterClass = 'character_select';
static groupClass = 'group_select';
static bogusFolderClass = 'bogus_folder_select';
static selectModeClass = 'group_overlay_mode_select';
static selectedClass = 'character_selected';
static legacySelectedClass = 'bulk_select_checkbox';
static bulkSelectedCountId = 'bulkSelectedCount';
static longPressDelay = 2500;
#state = BulkEditOverlayState.browse;
#longPress = false;
#stateChangeCallbacks = [];
#selectedCharacters = [];
#bulkTagPopupHandler = new BulkTagPopupHandler();
/**
* @typedef {object} LastSelected - An object noting the last selected character and its state.
* @property {number} [characterId] - The character id of the last selected character.
* @property {boolean} [select] - The selected state of the last selected character. <c>true</c> if it was selected, <c>false</c> if it was deselected.
*/
/**
* @type {LastSelected} - An object noting the last selected character and its state.
*/
lastSelected = { characterId: undefined, select: undefined };
/**
* Locks other pointer actions when the context menu is open
*
* @type {boolean}
*/
#contextMenuOpen = false;
/**
* Whether the next character select should be skipped
*
* @type {boolean}
*/
#cancelNextToggle = false;
/**
* @type HTMLElement
*/
container = null;
get state() {
return this.#state;
}
set state(newState) {
if (this.#state === newState) return;
eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE, newState)
.then(() => {
this.#state = newState;
eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.state);
});
}
get isLongPress() {
return this.#longPress;
}
set isLongPress(longPress) {
this.#longPress = longPress;
}
get stateChangeCallbacks() {
return this.#stateChangeCallbacks;
}
/**
*
* @returns {number[]}
*/
get selectedCharacters() {
return this.#selectedCharacters;
}
/**
* The instance of the bulk tag popup handler that handles tagging of all selected characters
*
* @returns {BulkTagPopupHandler}
*/
get bulkTagPopupHandler() {
return this.#bulkTagPopupHandler;
}
constructor() {
if (bulkEditOverlayInstance instanceof BulkEditOverlay)
return bulkEditOverlayInstance;
this.container = document.getElementById(BulkEditOverlay.containerId);
eventSource.on(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.handleStateChange);
bulkEditOverlayInstance = Object.freeze(this);
}
/**
* Set the overlay to browse mode
*/
browseState = () => this.state = BulkEditOverlayState.browse;
/**
* Set the overlay to select mode
*/
selectState = () => this.state = BulkEditOverlayState.select;
/**
* Set up a Sortable grid for the loaded page
*/
onPageLoad = () => {
this.browseState();
const elements = this.#getEnabledElements();
elements.forEach(element => element.addEventListener('touchstart', this.handleHold));
elements.forEach(element => element.addEventListener('mousedown', this.handleHold));
elements.forEach(element => element.addEventListener('contextmenu', this.handleDefaultContextMenu));
elements.forEach(element => element.addEventListener('touchend', this.handleLongPressEnd));
elements.forEach(element => element.addEventListener('mouseup', this.handleLongPressEnd));
elements.forEach(element => element.addEventListener('dragend', this.handleLongPressEnd));
elements.forEach(element => element.addEventListener('touchmove', this.handleLongPressEnd));
// Cohee: It only triggers when clicking on a margin between the elements?
// Feel free to fix or remove this, I'm not sure how to.
//this.container.addEventListener('click', this.handleCancelClick);
};
/**
* Handle state changes
*
*
*/
handleStateChange = () => {
switch (this.state) {
case BulkEditOverlayState.browse:
this.container.classList.remove(BulkEditOverlay.selectModeClass);
this.#contextMenuOpen = false;
this.#enableClickEventsForCharacters();
this.#enableClickEventsForGroups();
this.clearSelectedCharacters();
this.disableContextMenu();
this.#disableBulkEditButtonHighlight();
CharacterContextMenu.hide();
break;
case BulkEditOverlayState.select:
this.container.classList.add(BulkEditOverlay.selectModeClass);
this.#disableClickEventsForCharacters();
this.#disableClickEventsForGroups();
this.enableContextMenu();
this.#enableBulkEditButtonHighlight();
break;
}
this.stateChangeCallbacks.forEach(callback => callback(this.state));
};
/**
* Block the browsers native context menu and
* set a click event to hide the custom context menu.
*/
enableContextMenu = () => {
this.container.addEventListener('contextmenu', this.handleContextMenuShow);
document.addEventListener('click', this.handleContextMenuHide);
};
/**
* Remove event listeners, allowing the native browser context
* menu to be opened.
*/
disableContextMenu = () => {
this.container.removeEventListener('contextmenu', this.handleContextMenuShow);
document.removeEventListener('click', this.handleContextMenuHide);
};
handleDefaultContextMenu = (event) => {
if (this.isLongPress) {
event.preventDefault();
event.stopPropagation();
return false;
}
};
/**
* Opens menu on long-press.
*
* @param event - Pointer event
*/
handleHold = (event) => {
if (0 !== event.button && event.type !== 'touchstart') return;
if (this.#contextMenuOpen) {
this.#contextMenuOpen = false;
this.#cancelNextToggle = true;
CharacterContextMenu.hide();
return;
}
let cancel = false;
const cancelHold = (event) => cancel = true;
this.container.addEventListener('mouseup', cancelHold);
this.container.addEventListener('touchend', cancelHold);
this.isLongPress = true;
setTimeout(() => {
if (this.isLongPress && !cancel) {
if (this.state === BulkEditOverlayState.browse) {
this.selectState();
} else if (this.state === BulkEditOverlayState.select) {
this.#contextMenuOpen = true;
const [x, y] = this.#getContextMenuPosition(event);
CharacterContextMenu.show(x, y);
}
}
this.container.removeEventListener('mouseup', cancelHold);
this.container.removeEventListener('touchend', cancelHold);
}, BulkEditOverlay.longPressDelay);
};
handleLongPressEnd = (event) => {
this.isLongPress = false;
if (this.#contextMenuOpen) event.stopPropagation();
};
handleCancelClick = () => {
if (false === this.#contextMenuOpen) this.state = BulkEditOverlayState.browse;
this.#contextMenuOpen = false;
};
/**
* Returns the position of the mouse/touch location
*
* @param event
* @returns {(boolean|number|*)[]}
*/
#getContextMenuPosition = (event) => [
event.clientX || event.touches[0].clientX,
event.clientY || event.touches[0].clientY,
];
#stopEventPropagation = (event) => {
if (this.#contextMenuOpen) {
this.handleContextMenuHide(event);
}
event.stopPropagation();
};
#enableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.removeEventListener('click', this.#stopEventPropagation));
#disableClickEventsForGroups = () => this.#getDisabledElements().forEach((element) => element.addEventListener('click', this.#stopEventPropagation));
#enableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.removeEventListener('click', this.toggleCharacterSelected));
#disableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.addEventListener('click', this.toggleCharacterSelected));
#enableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.add('bulk_edit_overlay_active');
#disableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.remove('bulk_edit_overlay_active');
#getEnabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.characterClass)];
#getDisabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.groupClass), ...this.container.getElementsByClassName(BulkEditOverlay.bogusFolderClass)];
toggleCharacterSelected = event => {
event.stopPropagation();
const character = event.currentTarget;
if (!this.#contextMenuOpen && !this.#cancelNextToggle) {
if (event.shiftKey) {
// Shift click might have selected text that we don't want to. Unselect it.
document.getSelection().removeAllRanges();
this.handleShiftClick(character);
} else {
this.toggleSingleCharacter(character);
}
}
this.#cancelNextToggle = false;
};
/**
* When shift click was held down, this function handles the multi select of characters in a single click.
*
* If the last clicked character was deselected, and the current one was deselected too, it will deselect all currently selected characters between those two.
* If the last clicked character was selected, and the current one was selected too, it will select all currently not selected characters between those two.
* If the states do not match, nothing will happen.
*
* @param {HTMLElement} currentCharacter - The html element of the currently toggled character
*/
handleShiftClick = (currentCharacter) => {
const characterId = Number(currentCharacter.getAttribute('data-chid'));
const select = !this.selectedCharacters.includes(characterId);
if (this.lastSelected.characterId >= 0 && this.lastSelected.select !== undefined) {
// Only if select state and the last select state match we execute the range select
if (select === this.lastSelected.select) {
this.toggleCharactersInRange(currentCharacter, select);
}
}
};
/**
* Toggles the selection of a given characters
*
* @param {HTMLElement} character - The html element of a character
* @param {object} param1 - Optional params
* @param {boolean} [param1.markState] - Whether the toggle of this character should be remembered as the last done toggle
*/
toggleSingleCharacter = (character, { markState = true } = {}) => {
const characterId = Number(character.getAttribute('data-chid'));
const select = !this.selectedCharacters.includes(characterId);
const legacyBulkEditCheckbox = /** @type {HTMLInputElement} */ (character.querySelector('.' + BulkEditOverlay.legacySelectedClass));
if (select) {
character.classList.add(BulkEditOverlay.selectedClass);
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true;
this.#selectedCharacters.push(characterId);
} else {
character.classList.remove(BulkEditOverlay.selectedClass);
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
this.#selectedCharacters = this.#selectedCharacters.filter(item => characterId !== item);
}
this.updateSelectedCount();
if (markState) {
this.lastSelected.characterId = characterId;
this.lastSelected.select = select;
}
};
/**
* Updates the selected count element with the current count
*
* @param {number} [countOverride] - optional override for a manual number to set
*/
updateSelectedCount = (countOverride = undefined) => {
const count = countOverride ?? this.selectedCharacters.length;
$(`#${BulkEditOverlay.bulkSelectedCountId}`).text(count).attr('title', `${count} characters selected`);
};
/**
* Toggles the selection of characters in a given range.
* The range is provided by the given character and the last selected one remembered in the selection state.
*
* @param {HTMLElement} currentCharacter - The html element of the currently toggled character
* @param {boolean} select - <c>true</c> if the characters in the range are to be selected, <c>false</c> if deselected
*/
toggleCharactersInRange = (currentCharacter, select) => {
const currentCharacterId = Number(currentCharacter.getAttribute('data-chid'));
const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass));
const startIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === Number(this.lastSelected.characterId));
const endIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === currentCharacterId);
for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) {
const character = characters[i];
const characterId = Number(character.getAttribute('data-chid'));
const isCharacterSelected = this.selectedCharacters.includes(characterId);
// Only toggle the character if it wasn't on the state we have are toggling towards.
// Also doing a weird type check, because typescript checker doesn't like the return of 'querySelectorAll'.
if ((select && !isCharacterSelected || !select && isCharacterSelected) && character instanceof HTMLElement) {
this.toggleSingleCharacter(character, { markState: currentCharacterId == characterId });
}
}
};
handleContextMenuShow = (event) => {
event.preventDefault();
const [x,y] = this.#getContextMenuPosition(event);
CharacterContextMenu.show(x, y);
this.#contextMenuOpen = true;
};
handleContextMenuHide = (event) => {
let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
if (false === contextMenu.contains(event.target)) {
CharacterContextMenu.hide();
this.#contextMenuOpen = false;
}
};
/**
* Concurrently handle character favorite requests.
*
* @returns {Promise<void>}
*/
handleContextMenuFavorite = async () => {
const promises = [];
for (const characterId of this.selectedCharacters) {
promises.push(CharacterContextMenu.favorite(characterId));
}
await Promise.allSettled(promises);
await getCharacters();
await favsToHotswap();
this.browseState();
};
/**
* Concurrently handle character duplicate requests.
*
* @returns {Promise<number>}
*/
handleContextMenuDuplicate = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.duplicate(characterId)))
.then(() => getCharacters())
.then(() => this.browseState());
/**
* Sequentially handle all character-to-persona conversions.
*
* @returns {Promise<void>}
*/
handleContextMenuPersona = async () => {
for (const characterId of this.selectedCharacters) {
await CharacterContextMenu.persona(characterId);
}
this.browseState();
};
/**
* Gets the HTML as a string that is displayed inside the popup for the bulk delete
*
* @param {Array<number>} characterIds - The characters that are shown inside the popup
* @returns String containing the html for the popup content
*/
static #getDeletePopupContentHtml = (characterIds) => {
return `
<h3 class="marginBot5">Delete ${characterIds.length} characters?</h3>
<span class="bulk_delete_note">
<i class="fa-solid fa-triangle-exclamation warning margin-r5"></i>
<b>THIS IS PERMANENT!</b>
</span>
<div id="bulk_delete_avatars_block" class="avatars_inline avatars_inline_small tags tags_inline m-t-1"></div>
<br>
<div id="bulk_delete_options" class="m-b-1">
<label for="del_char_checkbox" class="checkbox_label justifyCenter">
<input type="checkbox" id="del_char_checkbox" />
<span>Also delete the chat files</span>
</label>
</div>`;
};
/**
* Request user input before concurrently handle deletion
* requests.
*
* @returns {Promise<number>}
*/
handleContextMenuDelete = () => {
const characterIds = this.selectedCharacters;
const popupContent = $(BulkEditOverlay.#getDeletePopupContentHtml(characterIds));
const checkbox = popupContent.find('#del_char_checkbox');
const promise = callGenericPopup(popupContent, POPUP_TYPE.CONFIRM)
.then((accept) => {
if (!accept) return;
const deleteChats = checkbox.prop('checked') ?? false;
showLoader();
const toast = toastr.info('We\'re deleting your characters, please wait...', 'Working on it');
const avatarList = characterIds.map(id => characters[id]?.avatar).filter(a => a);
return CharacterContextMenu.delete(avatarList, deleteChats)
.then(() => this.browseState())
.finally(() => {
toastr.clear(toast);
hideLoader();
});
});
// At this moment the popup is already changed in the dom, but not yet closed/resolved. We build the avatar list here
const entities = characterIds.map(id => characterToEntity(characters[id], id)).filter(entity => entity.item !== undefined);
buildAvatarList($('#bulk_delete_avatars_block'), entities);
return promise;
};
/**
* Attaches and opens the tag menu
*/
handleContextMenuTag = () => {
CharacterContextMenu.tag(this.selectedCharacters);
this.browseState();
};
addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback);
/**
* Clears internal character storage and
* removes visual highlight.
*/
clearSelectedCharacters = () => {
document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.selectedClass)
.forEach(element => element.classList.remove(BulkEditOverlay.selectedClass));
this.selectedCharacters.length = 0;
};
}
export { BulkEditOverlayState, CharacterContextMenu, BulkEditOverlay };

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,130 @@
/**
* Shared module between login and main app.
* Be careful what you import!
*/
const buttonSelectors = [
'.menu_button',
'.right_menu_button',
'.mes_button',
'.drawer-icon',
'.inline-drawer-icon',
'.swipe_left',
'.swipe_right',
'.character_select',
'.tags .tag',
'.jg-menu .jg-button',
'.bg_example .mobile-only-menu-toggle',
'.paginationjs-pages li a',
].join(', ');
const listSelectors = [
'.options-content',
'.list-group',
'#rm_print_characters_block',
'#rm_group_members',
'#rm_group_add_members',
'.tag_view_list_tags',
'.secretKeyManagerList',
'.recentChatList',
'.dataMaidCategoryContent',
'#userList',
'.bg_list',
].join(', ');
const listItemSelectors = [
'.options-content .list-group-item',
'.list-group .list-group-item',
'#rm_print_characters_block .entity_block',
'#rm_group_members .group_member',
'#rm_group_add_members .group_member',
'.tag_view_list_tags .tag_view_item',
'.secretKeyManagerList .secretKeyManagerItem',
'.recentChatList .recentChat',
'.dataMaidCategoryContent .dataMaidItem',
'#userList .userSelect',
'.bg_list .bg_example',
].join(', ');
const toolbarSelectors = [
'.jg-menu',
].join(', ');
const tabListSelectors = [
'#bg_tabs .bg_tabs_list',
].join(', ');
const tabItemSelectors = [
'#bg_tabs .bg_tabs_list .bg_tab_button',
].join(', ');
/** @type {Record<string, (element: Element) => void>} */
const a11yRules = {
[buttonSelectors]: (element) => {
element.setAttribute('role', 'button');
},
[listSelectors]: (element) => {
element.setAttribute('role', 'list');
},
[listItemSelectors]: (element) => {
element.setAttribute('role', 'listitem');
},
[toolbarSelectors]: (element) => {
element.setAttribute('role', 'toolbar');
},
[tabListSelectors]: (element) => {
element.setAttribute('role', 'tablist');
},
[tabItemSelectors]: (element) => {
element.setAttribute('role', 'tab');
},
'#toast-container .toast': (element) => {
element.setAttribute('role', 'status');
},
};
/**
* Apply accessibility rules to an element.
* @param {Element} element Element to process.
*/
function applyA11yRules(element) {
try {
for (const [selector, rule] of Object.entries(a11yRules)) {
// Apply if the element directly matches the selector
if (element.matches(selector)) {
rule(element);
}
// Apply the rule to descendants
element.querySelectorAll(selector).forEach(rule);
}
} catch (error) {
console.error('Error applying accessibility rules to element:', element, error);
}
}
function setAccessibilityObserver() {
// Apply for existing elements
applyA11yRules(document.body);
// Setup observer for dynamic content
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const addedNode of mutation.addedNodes) {
if (addedNode instanceof Element && addedNode.nodeType === Node.ELEMENT_NODE) {
applyA11yRules(addedNode);
}
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
export function initAccessibility() {
setAccessibilityObserver();
}

View File

@@ -0,0 +1,605 @@
import { formatTime } from './utils.js';
export class AudioPlayer {
/**
* Creates an audio player instance
* @param {HTMLElement} audioElement - The audio element to control
* @param {HTMLElement} containerElement - The container element with player controls
* @param {Object} options - Configuration options
*/
constructor(audioElement, containerElement, options = {}) {
if (!(audioElement instanceof HTMLAudioElement)) {
throw new Error('First argument must be an HTMLAudioElement');
}
if (!(containerElement instanceof HTMLElement)) {
throw new Error('Second argument must be an HTMLElement');
}
this.audio = audioElement;
this.container = containerElement;
this.options = {
title: '',
autoplay: false,
volume: 1.0,
onPlay: null,
onPause: null,
onEnded: null,
onTimeUpdate: null,
onVolumeChange: null,
...options,
};
this.isDragging = false;
this.isDestroyed = false;
// Store bound event handlers for cleanup
this.boundHandlers = {
// Audio event handlers
audioLoadedMetadata: this.onAudioLoadedMetadata.bind(this),
audioTimeUpdate: this.onAudioTimeUpdate.bind(this),
audioPlay: this.onAudioPlay.bind(this),
audioPause: this.onAudioPause.bind(this),
audioEnded: this.onAudioEnded.bind(this),
audioVolumeChange: this.onAudioVolumeChange.bind(this),
// Control event handlers
playPauseClick: this.onPlayPauseClick.bind(this),
volumeClick: this.onVolumeClick.bind(this),
volumeInput: this.onVolumeInput.bind(this),
progressMouseDown: this.onProgressMouseDown.bind(this),
progressClick: this.onProgressClick.bind(this),
progressMouseMove: this.onProgressMouseMove.bind(this),
documentMouseMove: this.onDocumentMouseMove.bind(this),
documentMouseUp: this.onDocumentMouseUp.bind(this),
};
// MutationObserver for DOM cleanup detection
this.observer = null;
this.init();
}
/**
* Initializes the audio player by setting up elements, events, and initial state
* @returns {void}
*/
init() {
this.findElements();
this.bindEvents();
this.setupDOMObserver();
if (this.options.title) {
this.setTitle(this.options.title);
} else if (this.audio.title) {
this.setTitle(this.audio.title);
} else if (this.audio.src) {
const srcParts = this.audio.src.split('/');
this.setTitle(decodeURIComponent(srcParts[srcParts.length - 1]));
}
if (this.options.autoplay) {
this.play();
}
this.setVolume(this.options.volume);
// Initialize time displays
this.updateTimeDisplays();
}
/**
* Finds and caches all required DOM elements within the container
* @returns {void}
*/
findElements() {
this.elements = {
title: this.container.querySelector('.audio-player-title'),
playPauseBtn: this.container.querySelector('.audio-player-play-pause'),
currentTime: this.container.querySelector('.audio-player-current-time'),
totalTime: this.container.querySelector('.audio-player-total-time'),
progress: this.container.querySelector('.audio-player-progress'),
progressBar: this.container.querySelector('.audio-player-progress-bar'),
volumeBtn: this.container.querySelector('.audio-player-volume'),
};
// Validate required elements
const requiredElements = ['playPauseBtn', 'currentTime', 'totalTime', 'progress', 'progressBar', 'volumeBtn'];
for (const key of requiredElements) {
if (!this.elements[key]) {
console.warn(`AudioPlayer: Required element .audio-player-${key.replace(/([A-Z])/g, '-$1').toLowerCase()} not found`);
}
}
}
/**
* Sets up a MutationObserver to detect when audio or container elements are removed from DOM
* @returns {void}
*/
setupDOMObserver() {
// Watch for removal of audio or container from DOM
this.observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
if (node === this.audio || node === this.container ||
node.contains?.(this.audio) || node.contains?.(this.container)) {
this.destroy();
return;
}
}
}
});
// Observe the parent nodes
const chatParent = this.audio.closest('#chat') ?? document.body;
if (chatParent) {
this.observer.observe(chatParent, { childList: true, subtree: true });
}
}
/**
* Binds all event listeners to audio and control elements
* @returns {void}
*/
bindEvents() {
// Audio events
this.audio.addEventListener('loadedmetadata', this.boundHandlers.audioLoadedMetadata);
this.audio.addEventListener('timeupdate', this.boundHandlers.audioTimeUpdate);
this.audio.addEventListener('play', this.boundHandlers.audioPlay);
this.audio.addEventListener('pause', this.boundHandlers.audioPause);
this.audio.addEventListener('ended', this.boundHandlers.audioEnded);
this.audio.addEventListener('volumechange', this.boundHandlers.audioVolumeChange);
// Control events
if (this.elements.playPauseBtn) {
this.elements.playPauseBtn.addEventListener('click', this.boundHandlers.playPauseClick);
}
if (this.elements.volumeBtn) {
this.elements.volumeBtn.addEventListener('click', this.boundHandlers.volumeClick);
}
if (this.elements.progress) {
this.elements.progress.addEventListener('mousedown', this.boundHandlers.progressMouseDown);
this.elements.progress.addEventListener('click', this.boundHandlers.progressClick);
this.elements.progress.addEventListener('mousemove', this.boundHandlers.progressMouseMove);
}
}
/**
* Removes all event listeners from audio and control elements
* @returns {void}
*/
unbindEvents() {
// Audio events
this.audio.removeEventListener('loadedmetadata', this.boundHandlers.audioLoadedMetadata);
this.audio.removeEventListener('timeupdate', this.boundHandlers.audioTimeUpdate);
this.audio.removeEventListener('play', this.boundHandlers.audioPlay);
this.audio.removeEventListener('pause', this.boundHandlers.audioPause);
this.audio.removeEventListener('ended', this.boundHandlers.audioEnded);
this.audio.removeEventListener('volumechange', this.boundHandlers.audioVolumeChange);
// Control events
if (this.elements.playPauseBtn) {
this.elements.playPauseBtn.removeEventListener('click', this.boundHandlers.playPauseClick);
}
if (this.elements.volumeBtn) {
this.elements.volumeBtn.removeEventListener('click', this.boundHandlers.volumeClick);
}
if (this.elements.progress) {
this.elements.progress.removeEventListener('mousedown', this.boundHandlers.progressMouseDown);
this.elements.progress.removeEventListener('click', this.boundHandlers.progressClick);
this.elements.progress.removeEventListener('mousemove', this.boundHandlers.progressMouseMove);
}
// Document events
document.removeEventListener('mousemove', this.boundHandlers.documentMouseMove);
document.removeEventListener('mouseup', this.boundHandlers.documentMouseUp);
}
// Audio event handlers
/**
* Handles the audio element's loadedmetadata event
* @returns {void}
*/
onAudioLoadedMetadata() {
if (this.isDestroyed) return;
this.updateTimeDisplays();
}
/**
* Handles the audio element's timeupdate event
* @returns {void}
*/
onAudioTimeUpdate() {
if (this.isDestroyed || this.isDragging) return;
const percent = (this.audio.currentTime / this.audio.duration) * 100 || 0;
if (this.elements.progressBar) {
/** @type {HTMLElement} */ (this.elements.progressBar).style.width = percent + '%';
}
if (this.elements.currentTime) {
this.elements.currentTime.textContent = formatTime(this.audio.currentTime);
}
if (typeof this.options.onTimeUpdate === 'function') {
this.options.onTimeUpdate.call(this, this.audio.currentTime, this.audio.duration);
}
}
/**
* Handles the audio element's play event
* @returns {void}
*/
onAudioPlay() {
if (this.isDestroyed) return;
if (this.elements.playPauseBtn) {
this.elements.playPauseBtn.classList.remove('fa-play');
this.elements.playPauseBtn.classList.add('fa-pause');
this.elements.playPauseBtn.setAttribute('title', 'Pause');
}
if (typeof this.options.onPlay === 'function') {
this.options.onPlay.call(this);
}
}
/**
* Handles the audio element's pause event
* @returns {void}
*/
onAudioPause() {
if (this.isDestroyed) return;
if (this.elements.playPauseBtn) {
this.elements.playPauseBtn.classList.remove('fa-pause');
this.elements.playPauseBtn.classList.add('fa-play');
this.elements.playPauseBtn.setAttribute('title', 'Play');
}
if (typeof this.options.onPause === 'function') {
this.options.onPause.call(this);
}
}
/**
* Handles the audio element's ended event
* @returns {void}
*/
onAudioEnded() {
if (this.isDestroyed) return;
if (this.elements.playPauseBtn) {
this.elements.playPauseBtn.classList.remove('fa-pause');
this.elements.playPauseBtn.classList.add('fa-play');
this.elements.playPauseBtn.setAttribute('title', 'Play');
}
if (typeof this.options.onEnded === 'function') {
this.options.onEnded.call(this);
}
}
/**
* Handles the audio element's volumechange event
* @returns {void}
*/
onAudioVolumeChange() {
if (this.isDestroyed) return;
this.updateVolumeIcon();
if (typeof this.options.onVolumeChange === 'function') {
this.options.onVolumeChange.call(this, this.audio.volume, this.audio.muted);
}
}
// Control event handlers
/**
* Handles click events on the play/pause button
* @param {MouseEvent} e - The click event
* @returns {void}
*/
onPlayPauseClick(e) {
e.preventDefault();
this.togglePlay();
}
/**
* Handles click events on the volume button
* @param {MouseEvent} e - The click event
* @returns {void}
*/
onVolumeClick(e) {
e.preventDefault();
this.toggleMute();
}
/**
* Handles input events on the volume slider
* @param {InputEvent} e - The input event
* @returns {void}
*/
onVolumeInput(e) {
if (!(e.target instanceof HTMLInputElement)) return;
const value = parseFloat(e.target.value);
this.setVolume(value);
}
/**
* Handles mousedown events on the progress bar
* @param {MouseEvent} e - The mousedown event
* @returns {void}
*/
onProgressMouseDown(e) {
this.isDragging = true;
this.updateProgress(e);
document.addEventListener('mousemove', this.boundHandlers.documentMouseMove);
document.addEventListener('mouseup', this.boundHandlers.documentMouseUp);
}
/**
* Handles click events on the progress bar
* @param {MouseEvent} e - The click event
* @returns {void}
*/
onProgressClick(e) {
if (!this.isDragging) {
this.updateProgress(e);
}
}
/**
* Handles mousemove on the progress bar (no-op if dragging)
* @param {MouseEvent} e - The mousemove event
* @returns {void}
*/
onProgressMouseMove(e) {
if (!this.isDragging) {
this.updateProgressTitle(e);
}
}
/**
* Handles document mousemove events during progress bar dragging
* @param {MouseEvent} e - The mousemove event
* @returns {void}
*/
onDocumentMouseMove(e) {
if (this.isDragging) {
this.updateProgress(e);
}
}
/**
* Handles document mouseup events to end progress bar dragging
* @returns {void}
*/
onDocumentMouseUp() {
if (this.isDragging) {
this.isDragging = false;
document.removeEventListener('mousemove', this.boundHandlers.documentMouseMove);
document.removeEventListener('mouseup', this.boundHandlers.documentMouseUp);
}
}
/**
* Updates the progress bar position and seeks audio based on mouse position
* @param {MouseEvent} e - The mouse event containing position information
* @returns {void}
*/
updateProgress(e) {
if (!this.elements.progress) return;
const rect = this.elements.progress.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const width = rect.width;
const percent = Math.max(0, Math.min(100, (offsetX / width) * 100));
if (this.elements.progressBar) {
/** @type {HTMLElement} */ (this.elements.progressBar).style.width = percent + '%';
}
const seekTime = (percent / 100) * this.audio.duration;
if (isFinite(seekTime)) {
this.audio.currentTime = seekTime;
if (this.elements.currentTime) {
this.elements.currentTime.textContent = formatTime(seekTime);
}
}
}
/**
* Updates the volume icon based on current volume and mute state
* @returns {void}
*/
updateVolumeIcon() {
if (!this.elements.volumeBtn) return;
const volume = this.audio.volume;
const isMuted = this.audio.muted;
this.elements.volumeBtn.classList.remove('fa-volume-high', 'fa-volume-low', 'fa-volume-off', 'fa-volume-xmark');
if (isMuted || volume === 0) {
this.elements.volumeBtn.classList.add('fa-volume-xmark');
} else if (volume < 0.5) {
this.elements.volumeBtn.classList.add('fa-volume-low');
} else {
this.elements.volumeBtn.classList.add('fa-volume-high');
}
}
/**
* Updates the current time and total time display elements
* @returns {void}
*/
updateTimeDisplays() {
if (this.elements.currentTime) {
this.elements.currentTime.textContent = formatTime(this.audio.currentTime || 0);
}
if (this.elements.totalTime) {
this.elements.totalTime.textContent = formatTime(this.audio.duration || 0);
}
}
/**
* Updates the mouseover title on the progress bar to show time at cursor position
* @param {MouseEvent} e - The mouse event
* @returns {void}
*/
updateProgressTitle(e) {
if (!this.elements.progress) return;
const rect = this.elements.progress.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const width = rect.width;
const percent = Math.max(0, Math.min(100, (offsetX / width) * 100));
this.elements.progress.setAttribute('title', formatTime((percent / 100) * this.audio.duration));
}
// Public methods
/**
* Starts audio playback
* @returns {void}
*/
play() {
if (this.isDestroyed) return;
if (this.audio.paused) {
const playPromise = this.audio.play();
if (playPromise !== undefined) {
playPromise.catch(error => {
console.error('Audio play failed:', error);
});
}
}
}
/**
* Pauses audio playback
* @returns {void}
*/
pause() {
if (this.isDestroyed) return;
if (!this.audio.paused) {
this.audio.pause();
}
}
/**
* Toggles between play and pause states
* @returns {void}
*/
togglePlay() {
if (this.audio.paused) {
this.play();
} else {
this.pause();
}
}
/**
* Seeks to a specific time in the audio
* @param {number} time - The time in seconds to seek to
* @returns {void}
*/
seek(time) {
if (this.isDestroyed) return;
if (isFinite(time) && time >= 0 && time <= this.audio.duration) {
this.audio.currentTime = time;
}
}
/**
* Sets the volume level
* @param {number} volume - Volume level between 0.0 and 1.0
* @returns {void}
*/
setVolume(volume) {
if (this.isDestroyed) return;
volume = Math.max(0, Math.min(1, volume));
this.audio.volume = volume;
if (volume > 0 && this.audio.muted) {
this.audio.muted = false;
}
}
/**
* Mutes the audio
* @returns {void}
*/
mute() {
if (this.isDestroyed) return;
this.audio.muted = true;
}
/**
* Unmutes the audio
* @returns {void}
*/
unmute() {
if (this.isDestroyed) return;
this.audio.muted = false;
}
/**
* Toggles the mute state
* @returns {void}
*/
toggleMute() {
if (this.isDestroyed) return;
this.audio.muted = !this.audio.muted;
}
/**
* Sets the audio source URL
* @param {string} src - The URL of the audio file
* @returns {void}
*/
setSrc(src) {
if (this.isDestroyed) return;
this.audio.src = src;
}
/**
* Sets the title displayed in the player
* @param {string} title - The title text to display
* @returns {void}
*/
setTitle(title) {
if (this.isDestroyed) return;
this.options.title = title;
if (this.elements.title) {
this.elements.title.textContent = title;
}
}
/**
* Cleans up the player by removing event listeners and clearing references
* @returns {void}
*/
destroy() {
if (this.isDestroyed) return;
this.isDestroyed = true;
// Stop observing DOM changes
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Pause and clear audio
this.pause();
this.audio.src = '';
// Remove all event listeners
this.unbindEvents();
// Clear references to prevent memory leaks
this.audio = null;
this.container = null;
this.elements = null;
this.options = null;
this.boundHandlers = null;
}
}

View File

@@ -0,0 +1,342 @@
/**
* 云酒馆 - 用户认证模块
* 处理用户登录和注册功能
*/
// ===== 配置 =====
const CONFIG = {
API_BASE_URL: 'http://localhost:8888/app',
TOKEN_KEY: 'st_access_token',
REFRESH_TOKEN_KEY: 'st_refresh_token',
USER_KEY: 'st_user_info',
};
// ===== DOM 元素 =====
const elements = {
tabs: document.querySelectorAll('.auth-tab'),
loginForm: document.getElementById('loginForm'),
registerForm: document.getElementById('registerForm'),
messageBox: document.getElementById('messageBox'),
loadingOverlay: document.getElementById('loadingOverlay'),
passwordToggles: document.querySelectorAll('.password-toggle'),
};
// ===== 初始化 =====
document.addEventListener('DOMContentLoaded', () => {
initTabs();
initForms();
initPasswordToggles();
checkAutoLogin();
});
// ===== 标签页切换 =====
function initTabs() {
elements.tabs.forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.dataset.tab;
switchTab(tabName);
});
});
}
function switchTab(tabName) {
// 更新标签页状态
elements.tabs.forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
// 更新表单显示
elements.loginForm.classList.toggle('active', tabName === 'login');
elements.registerForm.classList.toggle('active', tabName === 'register');
// 清空消息
hideMessage();
}
// ===== 表单初始化 =====
function initForms() {
// 登录表单
elements.loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
await handleLogin();
});
// 注册表单
elements.registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
await handleRegister();
});
}
// ===== 密码显示/隐藏切换 =====
function initPasswordToggles() {
elements.passwordToggles.forEach(toggle => {
toggle.addEventListener('click', () => {
const targetId = toggle.dataset.target;
const input = document.getElementById(targetId);
const icon = toggle.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
} else {
input.type = 'password';
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
}
});
});
}
// ===== 登录处理 =====
async function handleLogin() {
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
const rememberMe = document.getElementById('rememberMe').checked;
// 验证输入
if (!username || !password) {
showMessage('请填写完整的登录信息', 'error');
return;
}
showLoading();
try {
const response = await fetch(`${CONFIG.API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (data.code === 0) {
// 登录成功
saveLoginData(data.data, rememberMe);
showMessage('登录成功!正在跳转...', 'success');
// 延迟跳转到主页
setTimeout(() => {
window.location.href = '/';
}, 1500);
} else {
// 登录失败
showMessage(data.msg || '登录失败,请检查用户名和密码', 'error');
}
} catch (error) {
console.error('登录错误:', error);
showMessage('网络错误,请检查服务器连接', 'error');
} finally {
hideLoading();
}
}
// ===== 注册处理 =====
async function handleRegister() {
const username = document.getElementById('registerUsername').value.trim();
const password = document.getElementById('registerPassword').value;
const passwordConfirm = document.getElementById('registerPasswordConfirm').value;
const nickName = document.getElementById('registerNickname').value.trim();
const email = document.getElementById('registerEmail').value.trim();
// 验证输入
if (!username || !password || !passwordConfirm) {
showMessage('请填写必填信息', 'error');
return;
}
if (username.length < 3 || username.length > 32) {
showMessage('用户名长度应为 3-32 个字符', 'error');
return;
}
if (password.length < 6 || password.length > 32) {
showMessage('密码长度应为 6-32 个字符', 'error');
return;
}
if (password !== passwordConfirm) {
showMessage('两次输入的密码不一致', 'error');
return;
}
// 验证用户名格式
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
showMessage('用户名只能包含字母、数字和下划线', 'error');
return;
}
showLoading();
try {
const requestBody = {
username,
password,
nickName: nickName || username, // 如果没有昵称,使用用户名
};
// 如果填写了邮箱,添加到请求中
if (email) {
requestBody.email = email;
}
const response = await fetch(`${CONFIG.API_BASE_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (data.code === 0) {
// 注册成功
showMessage('注册成功!请登录', 'success');
// 清空注册表单
elements.registerForm.reset();
// 切换到登录标签页,并填充用户名
setTimeout(() => {
switchTab('login');
document.getElementById('loginUsername').value = username;
document.getElementById('loginPassword').focus();
}, 1500);
} else {
// 注册失败
showMessage(data.msg || '注册失败,请稍后重试', 'error');
}
} catch (error) {
console.error('注册错误:', error);
showMessage('网络错误,请检查服务器连接', 'error');
} finally {
hideLoading();
}
}
// ===== 保存登录数据 =====
function saveLoginData(loginData, rememberMe) {
const { token, refreshToken, user } = loginData;
// 保存到 localStorage 或 sessionStorage
const storage = rememberMe ? localStorage : sessionStorage;
storage.setItem(CONFIG.TOKEN_KEY, token);
storage.setItem(CONFIG.REFRESH_TOKEN_KEY, refreshToken);
storage.setItem(CONFIG.USER_KEY, JSON.stringify(user));
}
// ===== 自动登录检查 =====
function checkAutoLogin() {
// 检查是否已经登录
const token = localStorage.getItem(CONFIG.TOKEN_KEY) ||
sessionStorage.getItem(CONFIG.TOKEN_KEY);
if (token) {
// 已登录,尝试验证 token 是否有效
verifyToken(token);
}
}
// ===== 验证 Token =====
async function verifyToken(token) {
try {
const response = await fetch(`${CONFIG.API_BASE_URL}/auth/userinfo`, {
method: 'GET',
headers: {
'x-token': token,
},
});
if (response.ok) {
const data = await response.json();
if (data.code === 0) {
// Token 有效,直接跳转到主页
showMessage('检测到已登录,正在跳转...', 'success');
setTimeout(() => {
window.location.href = '/';
}, 1000);
}
}
} catch (error) {
console.error('Token 验证失败:', error);
// Token 无效,清除存储
clearLoginData();
}
}
// ===== 清除登录数据 =====
function clearLoginData() {
localStorage.removeItem(CONFIG.TOKEN_KEY);
localStorage.removeItem(CONFIG.REFRESH_TOKEN_KEY);
localStorage.removeItem(CONFIG.USER_KEY);
sessionStorage.removeItem(CONFIG.TOKEN_KEY);
sessionStorage.removeItem(CONFIG.REFRESH_TOKEN_KEY);
sessionStorage.removeItem(CONFIG.USER_KEY);
}
// ===== 消息提示 =====
function showMessage(message, type = 'error') {
elements.messageBox.textContent = message;
elements.messageBox.className = `message-box show ${type}`;
// 3秒后自动隐藏
setTimeout(() => {
hideMessage();
}, 5000);
}
function hideMessage() {
elements.messageBox.classList.remove('show');
}
// ===== 加载动画 =====
function showLoading() {
elements.loadingOverlay.classList.add('show');
}
function hideLoading() {
elements.loadingOverlay.classList.remove('show');
}
// ===== 工具函数:获取当前登录用户 =====
export function getCurrentUser() {
const userJson = localStorage.getItem(CONFIG.USER_KEY) ||
sessionStorage.getItem(CONFIG.USER_KEY);
return userJson ? JSON.parse(userJson) : null;
}
// ===== 工具函数:获取 Token =====
export function getToken() {
return localStorage.getItem(CONFIG.TOKEN_KEY) ||
sessionStorage.getItem(CONFIG.TOKEN_KEY);
}
// ===== 工具函数:登出 =====
export async function logout() {
const token = getToken();
if (token) {
try {
await fetch(`${CONFIG.API_BASE_URL}/auth/logout`, {
method: 'POST',
headers: {
'x-token': token,
},
});
} catch (error) {
console.error('登出请求失败:', error);
}
}
clearLoginData();
window.location.href = '/auth.html';
}
// ===== 导出配置供其他模块使用 =====
export { CONFIG };

View File

@@ -0,0 +1,621 @@
import {
MAX_INJECTION_DEPTH,
animation_duration,
chat_metadata,
eventSource,
event_types,
extension_prompt_roles,
extension_prompt_types,
saveSettingsDebounced,
this_chid,
} from '../script.js';
import { selected_group } from './group-chats.js';
import { extension_settings, getContext, saveMetadataDebounced } from './extensions.js';
import { getCharaFilename, debounce, delay } from './utils.js';
import { getTokenCountAsync } from './tokenizers.js';
import { debounce_timeout } from './constants.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
export { MODULE_NAME as NOTE_MODULE_NAME };
import { t } from './i18n.js';
import { macros, MacroCategory } from './macros/macro-system.js';
import { MacrosParser } from './macros.js';
import { power_user } from './power-user.js';
const MODULE_NAME = '2_floating_prompt'; // <= Deliberate, for sorting lower than memory
export var shouldWIAddPrompt = false;
export const metadata_keys = {
prompt: 'note_prompt',
interval: 'note_interval',
depth: 'note_depth',
position: 'note_position',
role: 'note_role',
};
const chara_note_position = {
replace: 0,
before: 1,
after: 2,
};
function setNoteTextCommand(_, text) {
if (text) {
$('#extension_floating_prompt').val(text).trigger('input');
toastr.success(t`Author's Note text updated`);
}
return chat_metadata[metadata_keys.prompt];
}
function setNoteDepthCommand(_, text) {
if (text) {
const value = Number(text);
if (Number.isNaN(value)) {
toastr.error(t`Not a valid number`);
return;
}
$('#extension_floating_depth').val(Math.abs(value)).trigger('input');
toastr.success(t`Author's Note depth updated`);
}
return chat_metadata[metadata_keys.depth];
}
function setNoteIntervalCommand(_, text) {
if (text) {
const value = Number(text);
if (Number.isNaN(value)) {
toastr.error(t`Not a valid number`);
return;
}
$('#extension_floating_interval').val(Math.abs(value)).trigger('input');
toastr.success(t`Author's Note frequency updated`);
}
return chat_metadata[metadata_keys.interval];
}
function setNotePositionCommand(_, text) {
const validPositions = {
'after': 0,
'scenario': 0,
'chat': 1,
'before_scenario': 2,
'before': 2,
};
if (text) {
const position = validPositions[text?.trim()?.toLowerCase()];
if (typeof position === 'undefined') {
toastr.error(t`Not a valid position`);
return;
}
$(`input[name="extension_floating_position"][value="${position}"]`).prop('checked', true).trigger('input');
toastr.info(t`Author's Note position updated`);
}
return Object.keys(validPositions).find(key => validPositions[key] == chat_metadata[metadata_keys.position]);
}
function setNoteRoleCommand(_, text) {
const validRoles = {
'system': 0,
'user': 1,
'assistant': 2,
};
if (text) {
const role = validRoles[text?.trim()?.toLowerCase()];
if (typeof role === 'undefined') {
toastr.error(t`Not a valid role`);
return;
}
$('#extension_floating_role').val(Math.abs(role)).trigger('input');
toastr.info(t`Author's Note role updated`);
}
return Object.keys(validRoles).find(key => validRoles[key] == chat_metadata[metadata_keys.role]);
}
function updateSettings() {
saveSettingsDebounced();
loadSettings();
setFloatingPrompt();
}
const setMainPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_prompt_token_counter').text(await getTokenCountAsync(value)), debounce_timeout.relaxed);
const setCharaPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_chara_token_counter').text(await getTokenCountAsync(value)), debounce_timeout.relaxed);
const setDefaultPromptTokenCounterDebounced = debounce(async (value) => $('#extension_floating_default_token_counter').text(await getTokenCountAsync(value)), debounce_timeout.relaxed);
async function onExtensionFloatingPromptInput() {
chat_metadata[metadata_keys.prompt] = $(this).val();
setMainPromptTokenCounterDebounced(chat_metadata[metadata_keys.prompt]);
updateSettings();
saveMetadataDebounced();
}
async function onExtensionFloatingIntervalInput() {
chat_metadata[metadata_keys.interval] = Number($(this).val());
updateSettings();
saveMetadataDebounced();
}
async function onExtensionFloatingDepthInput() {
let value = Number($(this).val());
if (value < 0) {
value = Math.abs(value);
$(this).val(value);
}
chat_metadata[metadata_keys.depth] = value;
updateSettings();
saveMetadataDebounced();
}
async function onExtensionFloatingPositionInput(e) {
chat_metadata[metadata_keys.position] = Number(e.target.value);
updateSettings();
saveMetadataDebounced();
}
async function onDefaultPositionInput(e) {
extension_settings.note.defaultPosition = Number(e.target.value);
saveSettingsDebounced();
}
async function onDefaultDepthInput() {
let value = Number($(this).val());
if (value < 0) {
value = Math.abs(value);
$(this).val(value);
}
extension_settings.note.defaultDepth = value;
saveSettingsDebounced();
}
async function onDefaultIntervalInput() {
extension_settings.note.defaultInterval = Number($(this).val());
saveSettingsDebounced();
}
function onExtensionFloatingRoleInput(e) {
chat_metadata[metadata_keys.role] = Number(e.target.value);
updateSettings();
}
function onExtensionDefaultRoleInput(e) {
extension_settings.note.defaultRole = Number(e.target.value);
saveSettingsDebounced();
}
async function onExtensionFloatingCharPositionInput(e) {
const value = e.target.value;
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
if (charaNote) {
charaNote.position = Number(value);
updateSettings();
}
}
function onExtensionFloatingCharaPromptInput() {
const tempPrompt = $(this).val();
const avatarName = getCharaFilename();
let tempCharaNote = {
name: avatarName,
prompt: tempPrompt,
};
setCharaPromptTokenCounterDebounced(tempPrompt);
let existingCharaNoteIndex;
let existingCharaNote;
if (extension_settings.note.chara) {
existingCharaNoteIndex = extension_settings.note.chara.findIndex((e) => e.name === avatarName);
existingCharaNote = extension_settings.note.chara[existingCharaNoteIndex];
}
if (tempPrompt.length === 0 &&
extension_settings.note.chara &&
existingCharaNote &&
!existingCharaNote.useChara
) {
extension_settings.note.chara.splice(existingCharaNoteIndex, 1);
}
else if (extension_settings.note.chara && existingCharaNote) {
Object.assign(existingCharaNote, tempCharaNote);
}
else if (avatarName && tempPrompt.length > 0) {
if (!extension_settings.note.chara) {
extension_settings.note.chara = [];
}
Object.assign(tempCharaNote, { useChara: false, position: chara_note_position.replace });
extension_settings.note.chara.push(tempCharaNote);
} else {
console.log('Character author\'s note error: No avatar name key could be found.');
toastr.error(t`Something went wrong. Could not save character's author's note.`);
// Don't save settings if something went wrong
return;
}
updateSettings();
}
function onExtensionFloatingCharaCheckboxChanged() {
const value = !!$(this).prop('checked');
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
if (charaNote) {
charaNote.useChara = value;
updateSettings();
}
}
function onExtensionFloatingDefaultInput() {
extension_settings.note.default = $(this).val();
setDefaultPromptTokenCounterDebounced(extension_settings.note.default);
updateSettings();
}
function loadSettings() {
const DEFAULT_DEPTH = 4;
const DEFAULT_POSITION = 1;
const DEFAULT_INTERVAL = 1;
const DEFAULT_ROLE = extension_prompt_roles.SYSTEM;
if (extension_settings.note.defaultPosition === undefined) {
extension_settings.note.defaultPosition = DEFAULT_POSITION;
}
if (extension_settings.note.defaultDepth === undefined) {
extension_settings.note.defaultDepth = DEFAULT_DEPTH;
}
if (extension_settings.note.defaultInterval === undefined) {
extension_settings.note.defaultInterval = DEFAULT_INTERVAL;
}
if (extension_settings.note.defaultRole === undefined) {
extension_settings.note.defaultRole = DEFAULT_ROLE;
}
chat_metadata[metadata_keys.prompt] = chat_metadata[metadata_keys.prompt] ?? extension_settings.note.default ?? '';
chat_metadata[metadata_keys.interval] = chat_metadata[metadata_keys.interval] ?? extension_settings.note.defaultInterval ?? DEFAULT_INTERVAL;
chat_metadata[metadata_keys.position] = chat_metadata[metadata_keys.position] ?? extension_settings.note.defaultPosition ?? DEFAULT_POSITION;
chat_metadata[metadata_keys.depth] = chat_metadata[metadata_keys.depth] ?? extension_settings.note.defaultDepth ?? DEFAULT_DEPTH;
chat_metadata[metadata_keys.role] = chat_metadata[metadata_keys.role] ?? extension_settings.note.defaultRole ?? DEFAULT_ROLE;
$('#extension_floating_prompt').val(chat_metadata[metadata_keys.prompt]);
$('#extension_floating_interval').val(chat_metadata[metadata_keys.interval]);
$('#extension_floating_allow_wi_scan').prop('checked', extension_settings.note.allowWIScan ?? false);
$('#extension_floating_depth').val(chat_metadata[metadata_keys.depth]);
$('#extension_floating_role').val(chat_metadata[metadata_keys.role]);
$(`input[name="extension_floating_position"][value="${chat_metadata[metadata_keys.position]}"]`).prop('checked', true);
if (extension_settings.note.chara && getContext().characterId !== undefined) {
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
$('#extension_floating_chara').val(charaNote ? charaNote.prompt : '');
$('#extension_use_floating_chara').prop('checked', charaNote ? charaNote.useChara : false);
$(`input[name="extension_floating_char_position"][value="${charaNote?.position ?? chara_note_position.replace}"]`).prop('checked', true);
} else {
$('#extension_floating_chara').val('');
$('#extension_use_floating_chara').prop('checked', false);
$(`input[name="extension_floating_char_position"][value="${chara_note_position.replace}"]`).prop('checked', true);
}
$('#extension_floating_default').val(extension_settings.note.default);
$('#extension_default_depth').val(extension_settings.note.defaultDepth);
$('#extension_default_interval').val(extension_settings.note.defaultInterval);
$('#extension_default_role').val(extension_settings.note.defaultRole);
$(`input[name="extension_default_position"][value="${extension_settings.note.defaultPosition}"]`).prop('checked', true);
}
export function setFloatingPrompt() {
const context = getContext();
if (!context.groupId && context.characterId === undefined) {
console.debug('setFloatingPrompt: Not in a chat. Skipping.');
shouldWIAddPrompt = false;
return;
}
// take the count of messages
let lastMessageNumber = Array.isArray(context.chat) && context.chat.length ? context.chat.filter(m => m.is_user).length : 0;
console.debug(`
setFloatingPrompt entered
------
lastMessageNumber = ${lastMessageNumber}
metadata_keys.interval = ${chat_metadata[metadata_keys.interval]}
metadata_keys.position = ${chat_metadata[metadata_keys.position]}
metadata_keys.depth = ${chat_metadata[metadata_keys.depth]}
metadata_keys.role = ${chat_metadata[metadata_keys.role]}
------
`);
// interval 1 should be inserted no matter what
if (chat_metadata[metadata_keys.interval] === 1) {
lastMessageNumber = 1;
}
if (lastMessageNumber <= 0 || chat_metadata[metadata_keys.interval] <= 0) {
context.setExtensionPrompt(MODULE_NAME, '', extension_prompt_types.NONE, MAX_INJECTION_DEPTH);
$('#extension_floating_counter').text('(disabled)');
shouldWIAddPrompt = false;
return;
}
const messagesTillInsertion = lastMessageNumber >= chat_metadata[metadata_keys.interval]
? (lastMessageNumber % chat_metadata[metadata_keys.interval])
: (chat_metadata[metadata_keys.interval] - lastMessageNumber);
const shouldAddPrompt = messagesTillInsertion == 0;
shouldWIAddPrompt = shouldAddPrompt;
let prompt = shouldAddPrompt ? $('#extension_floating_prompt').val() : '';
if (shouldAddPrompt && extension_settings.note.chara && getContext().characterId !== undefined) {
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
// Only replace with the chara note if the user checked the box
if (charaNote && charaNote.useChara) {
switch (charaNote.position) {
case chara_note_position.before:
prompt = charaNote.prompt + '\n' + prompt;
break;
case chara_note_position.after:
prompt = prompt + '\n' + charaNote.prompt;
break;
default:
prompt = charaNote.prompt;
break;
}
}
}
context.setExtensionPrompt(
MODULE_NAME,
String(prompt),
chat_metadata[metadata_keys.position],
chat_metadata[metadata_keys.depth],
extension_settings.note.allowWIScan,
chat_metadata[metadata_keys.role],
);
$('#extension_floating_counter').text(shouldAddPrompt ? '0' : messagesTillInsertion);
}
function onANMenuItemClick() {
if (!selected_group && this_chid === undefined) {
toastr.warning(t`Select a character before trying to use Author's Note`, '', { timeOut: 2000 });
return;
}
//show AN if it's hidden
const $ANcontainer = $('#floatingPrompt');
if ($ANcontainer.css('display') !== 'flex') {
$ANcontainer.addClass('resizing');
$ANcontainer.css('display', 'flex');
$ANcontainer.css('opacity', 0.0);
$ANcontainer.transition({
opacity: 1.0,
duration: animation_duration,
}, async function () {
await delay(50);
$ANcontainer.removeClass('resizing');
});
//auto-open the main AN inline drawer
if ($('#ANBlockToggle')
.siblings('.inline-drawer-content')
.css('display') !== 'block') {
$ANcontainer.addClass('resizing');
$('#ANBlockToggle').trigger('click');
}
} else {
//hide AN if it's already displayed
$ANcontainer.addClass('resizing');
$ANcontainer.transition({
opacity: 0.0,
duration: animation_duration,
}, async function () {
await delay(50);
$ANcontainer.removeClass('resizing');
});
setTimeout(function () {
$ANcontainer.hide();
}, animation_duration);
}
//duplicate options menu close handler from script.js
//because this listener takes priority
$('#options').stop().fadeOut(animation_duration);
}
async function onChatChanged() {
loadSettings();
setFloatingPrompt();
const context = getContext();
// Disable the chara note if in a group
$('#extension_floating_chara').prop('disabled', !!context.groupId);
const tokenCounter1 = chat_metadata[metadata_keys.prompt] ? await getTokenCountAsync(chat_metadata[metadata_keys.prompt]) : 0;
$('#extension_floating_prompt_token_counter').text(tokenCounter1);
let tokenCounter2;
if (extension_settings.note.chara && context.characterId !== undefined) {
const charaNote = extension_settings.note.chara.find((e) => e.name === getCharaFilename());
if (charaNote) {
tokenCounter2 = await getTokenCountAsync(charaNote.prompt);
}
}
$('#extension_floating_chara_token_counter').text(tokenCounter2 || 0);
const tokenCounter3 = extension_settings.note.default ? await getTokenCountAsync(extension_settings.note.default) : 0;
$('#extension_floating_default_token_counter').text(tokenCounter3);
}
function onAllowWIScanCheckboxChanged() {
extension_settings.note.allowWIScan = !!$(this).prop('checked');
updateSettings();
}
/**
* Inject author's note options and setup event listeners.
*/
// Inserts the extension first since it's statically imported
export function initAuthorsNote() {
$('#extension_floating_prompt').on('input', onExtensionFloatingPromptInput);
$('#extension_floating_interval').on('input', onExtensionFloatingIntervalInput);
$('#extension_floating_depth').on('input', onExtensionFloatingDepthInput);
$('#extension_floating_chara').on('input', onExtensionFloatingCharaPromptInput);
$('#extension_use_floating_chara').on('input', onExtensionFloatingCharaCheckboxChanged);
$('#extension_floating_default').on('input', onExtensionFloatingDefaultInput);
$('#extension_default_depth').on('input', onDefaultDepthInput);
$('#extension_default_interval').on('input', onDefaultIntervalInput);
$('#extension_floating_allow_wi_scan').on('input', onAllowWIScanCheckboxChanged);
$('#extension_floating_role').on('input', onExtensionFloatingRoleInput);
$('#extension_default_role').on('input', onExtensionDefaultRoleInput);
$('input[name="extension_floating_position"]').on('change', onExtensionFloatingPositionInput);
$('input[name="extension_default_position"]').on('change', onDefaultPositionInput);
$('input[name="extension_floating_char_position"]').on('change', onExtensionFloatingCharPositionInput);
$('#ANClose').on('click', function () {
$('#floatingPrompt').transition({
opacity: 0,
duration: animation_duration,
easing: 'ease-in-out',
});
setTimeout(function () { $('#floatingPrompt').hide(); }, animation_duration);
});
$('#option_toggle_AN').on('click', onANMenuItemClick);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'note',
callback: setNoteTextCommand,
returns: 'current author\'s note',
unnamedArgumentList: [
new SlashCommandArgument(
'text', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Sets an author's note for the currently selected chat if specified and returns the current note.
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'note-depth',
aliases: ['depth'],
callback: setNoteDepthCommand,
returns: 'current author\'s note depth',
unnamedArgumentList: [
new SlashCommandArgument(
'number', [ARGUMENT_TYPE.NUMBER], false,
),
],
helpString: `
<div>
Sets an author's note depth for in-chat positioning if specified and returns the current depth.
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'note-frequency',
aliases: ['freq', 'note-freq'],
callback: setNoteIntervalCommand,
returns: 'current author\'s note insertion frequency',
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'number', [ARGUMENT_TYPE.NUMBER], false,
),
],
helpString: `
<div>
Sets an author's note insertion frequency if specified and returns the current frequency.
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'note-position',
callback: setNotePositionCommand,
aliases: ['pos', 'note-pos'],
returns: 'current author\'s note insertion position',
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'position', [ARGUMENT_TYPE.STRING], false, false, null, ['before', 'after', 'chat'],
),
],
helpString: `
<div>
Sets an author's note position if specified and returns the current position.
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'note-role',
callback: setNoteRoleCommand,
returns: 'current author\'s note chat insertion role',
namedArgumentList: [],
unnamedArgumentList: [
new SlashCommandArgument(
'role', [ARGUMENT_TYPE.STRING], false, false, null, ['system', 'user', 'assistant'],
),
],
helpString: `
<div>
Sets an author's note chat insertion role if specified and returns the current role.
</div>
`,
}));
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
registerAuthorsNoteMacros();
}
function registerAuthorsNoteMacros() {
if (power_user.experimental_macro_engine) {
macros.register('authorsNote', {
category: MacroCategory.PROMPTS,
description: t`The contents of the Author's Note`,
handler: () => chat_metadata[metadata_keys.prompt] ?? '',
});
macros.register('charAuthorsNote', {
category: MacroCategory.CHARACTER,
description: t`The contents of the Character Author's Note`,
handler: () => this_chid !== undefined ? (extension_settings.note.chara.find((e) => e.name === getCharaFilename())?.prompt ?? '') : '',
});
macros.register('defaultAuthorsNote', {
category: MacroCategory.PROMPTS,
description: t`The contents of the Default Author's Note`,
handler: () => extension_settings.note.default ?? '',
});
} else {
// TODO: Remove this when the experimental macro engine is replacing the old macro engine
MacrosParser.registerMacro('authorsNote',
() => chat_metadata[metadata_keys.prompt] ?? '',
t`The contents of the Author's Note`,
);
MacrosParser.registerMacro('charAuthorsNote',
() => this_chid !== undefined ? (extension_settings.note.chara.find((e) => e.name === getCharaFilename())?.prompt ?? '') : '',
t`The contents of the Character Author's Note`,
);
MacrosParser.registerMacro('defaultAuthorsNote',
() => extension_settings.note.default ?? '',
t`The contents of the Default Author's Note`,
);
}
}

View File

@@ -0,0 +1,828 @@
import { power_user } from '../power-user.js';
import { debounce, escapeRegex } from '../utils.js';
import { AutoCompleteOption } from './AutoCompleteOption.js';
import { AutoCompleteFuzzyScore } from './AutoCompleteFuzzyScore.js';
import { BlankAutoCompleteOption } from './BlankAutoCompleteOption.js';
import { AutoCompleteNameResult } from './AutoCompleteNameResult.js';
import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js';
/**@readonly*/
/**@enum {Number}*/
export const AUTOCOMPLETE_WIDTH = {
'INPUT': 0,
'CHAT': 1,
'FULL': 2,
};
/**@readonly*/
/**@enum {Number}*/
export const AUTOCOMPLETE_SELECT_KEY = {
'TAB': 1, // 2^0
'ENTER': 2, // 2^1
};
/** @readonly */
/** @enum {Number} */
export const AUTOCOMPLETE_STATE = {
DISABLED: 0,
MIN_LENGTH: 1,
ALWAYS: 2,
};
export class AutoComplete {
/**@type {HTMLTextAreaElement|HTMLInputElement}*/ textarea;
/**@type {boolean}*/ isFloating = false;
/**@type {()=>boolean}*/ checkIfActivate;
/**@type {(text:string, index:number) => Promise<AutoCompleteNameResult>}*/ getNameAt;
/**@type {boolean}*/ isActive = false;
/**@type {boolean}*/ isReplaceable = false;
/**@type {boolean}*/ isShowingDetails = false;
/**@type {boolean}*/ wasForced = false;
/**@type {boolean}*/ isForceHidden = false;
/**@type {boolean}*/ canBeAutoHidden = false;
/**@type {string}*/ text;
/**@type {AutoCompleteNameResult}*/ parserResult;
/**@type {AutoCompleteSecondaryNameResult}*/ secondaryParserResult;
get effectiveParserResult() { return this.secondaryParserResult ?? this.parserResult; }
/**@type {string}*/ name;
/**@type {boolean}*/ startQuote;
/**@type {boolean}*/ endQuote;
/**@type {number}*/ selectionStart;
/**@type {RegExp}*/ fuzzyRegex;
/**@type {AutoCompleteOption[]}*/ result = [];
/**@type {AutoCompleteOption}*/ selectedItem = null;
/**@type {HTMLElement}*/ clone;
/**@type {HTMLElement}*/ domWrap;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ detailsWrap;
/**@type {HTMLElement}*/ detailsDom;
/**@type {function}*/ renderDebounced;
/**@type {function}*/ renderDetailsDebounced;
/**@type {function}*/ updatePositionDebounced;
/**@type {function}*/ updateDetailsPositionDebounced;
/**@type {function}*/ updateFloatingPositionDebounced;
/**@type {(item:AutoCompleteOption)=>any}*/ onSelect;
get matchType() {
return power_user.stscript.matching ?? 'fuzzy';
}
get autoHide() {
return power_user.stscript.autocomplete.autoHide ?? false;
}
/**
* @param {HTMLTextAreaElement|HTMLInputElement} textarea The textarea to receive autocomplete.
* @param {() => boolean} checkIfActivate Function should return true only if under the current conditions, autocomplete should display (e.g., for slash commands: autoComplete.text[0] == '/')
* @param {(text: string, index: number) => Promise<AutoCompleteNameResult>} getNameAt Function should return (unfiltered, matching against input is done in AutoComplete) information about name options at index in text.
* @param {boolean} isFloating Whether autocomplete should float at the keyboard cursor.
*/
constructor(textarea, checkIfActivate, getNameAt, isFloating = false) {
this.textarea = textarea;
this.checkIfActivate = checkIfActivate;
this.getNameAt = getNameAt;
this.isFloating = isFloating;
this.domWrap = document.createElement('div'); {
this.domWrap.classList.add('autoComplete-wrap');
if (isFloating) this.domWrap.classList.add('isFloating');
}
this.dom = document.createElement('ul'); {
this.dom.classList.add('autoComplete');
this.domWrap.append(this.dom);
}
this.detailsWrap = document.createElement('div'); {
this.detailsWrap.classList.add('autoComplete-detailsWrap');
if (isFloating) this.detailsWrap.classList.add('isFloating');
}
this.detailsDom = document.createElement('div'); {
this.detailsDom.classList.add('autoComplete-details');
this.detailsWrap.append(this.detailsDom);
}
this.renderDebounced = debounce(this.render.bind(this), 10);
this.renderDetailsDebounced = debounce(this.renderDetails.bind(this), 10);
this.updatePositionDebounced = debounce(this.updatePosition.bind(this), 10);
this.updateDetailsPositionDebounced = debounce(this.updateDetailsPosition.bind(this), 10);
this.updateFloatingPositionDebounced = debounce(this.updateFloatingPosition.bind(this), 10);
textarea.addEventListener('input', () => {
this.selectionStart = this.textarea.selectionStart;
if (this.text != this.textarea.value) this.show(true, this.wasForced);
});
textarea.addEventListener('keydown', (evt) => this.handleKeyDown(evt));
textarea.addEventListener('click', () => {
this.selectionStart = this.textarea.selectionStart;
if (this.isActive) this.show();
});
textarea.addEventListener('blur', () => this.hide());
if (isFloating) {
textarea.addEventListener('scroll', () => this.updateFloatingPositionDebounced());
}
window.addEventListener('resize', () => this.updatePositionDebounced());
}
/**
*
* @param {AutoCompleteOption} option
*/
makeItem(option) {
const li = option.renderItem();
// gotta listen to pointerdown (happens before textarea-blur)
li.addEventListener('pointerdown', (evt) => {
evt.preventDefault();
this.selectedItem = this.result.find(it => it.name == li.getAttribute('data-name'));
this.select();
});
return li;
}
/**
*
* @param {AutoCompleteOption} item
*/
updateName(item) {
const chars = Array.from(item.dom.querySelector('.name').children);
switch (this.matchType) {
case 'strict': {
chars.forEach((it, idx) => {
if (idx + item.nameOffset < item.name.length) {
it.classList.add('matched');
} else {
it.classList.remove('matched');
}
});
break;
}
case 'includes': {
const start = item.name.toLowerCase().search(this.name);
chars.forEach((it, idx) => {
if (idx + item.nameOffset < start) {
it.classList.remove('matched');
} else if (idx + item.nameOffset < start + item.name.length) {
it.classList.add('matched');
} else {
it.classList.remove('matched');
}
});
break;
}
case 'fuzzy': {
item.name.replace(this.fuzzyRegex, (_, ...parts) => {
parts.splice(-2, 2);
if (parts.length == 2) {
chars.forEach(c => c.classList.remove('matched'));
} else {
let cIdx = item.nameOffset;
parts.forEach((it, idx) => {
if (it === null || it.length == 0) return '';
if (idx % 2 == 1) {
chars.slice(cIdx, cIdx + it.length).forEach(c => c.classList.add('matched'));
} else {
chars.slice(cIdx, cIdx + it.length).forEach(c => c.classList.remove('matched'));
}
cIdx += it.length;
});
}
return '';
});
}
}
return item;
}
/**
* Calculate score for the fuzzy match.
* @param {AutoCompleteOption} option
* @returns The option.
*/
fuzzyScore(option) {
// might have been matched by the options matchProvider function instead
if (!this.fuzzyRegex.test(option.name)) {
option.score = new AutoCompleteFuzzyScore(Number.MAX_SAFE_INTEGER, -1);
return option;
}
const parts = this.fuzzyRegex.exec(option.name).slice(1, -1);
let start = null;
let consecutive = [];
let current = '';
let offset = 0;
parts.forEach((part, idx) => {
if (idx % 2 == 0) {
if (part.length > 0) {
if (current.length > 0) {
consecutive.push(current);
}
current = '';
}
} else {
if (start === null) {
start = offset;
}
current += part;
}
offset += part.length;
});
if (current.length > 0) {
consecutive.push(current);
}
consecutive.sort((a, b) => b.length - a.length);
option.score = new AutoCompleteFuzzyScore(start, consecutive[0]?.length ?? 0);
return option;
}
/**
* Compare two auto complete options by their fuzzy score.
* @param {AutoCompleteOption} a
* @param {AutoCompleteOption} b
*/
fuzzyScoreCompare(a, b) {
if (a.score.start < b.score.start) return -1;
if (a.score.start > b.score.start) return 1;
if (a.score.longestConsecutive > b.score.longestConsecutive) return -1;
if (a.score.longestConsecutive < b.score.longestConsecutive) return 1;
return a.name.localeCompare(b.name);
}
basicAutoHideCheck() {
// auto hide only if at least one char has been typed after the name + space
return this.textarea.selectionStart > this.parserResult.start
+ this.parserResult.name.length
+ (this.startQuote ? 1 : 0)
+ (this.endQuote ? 1 : 0)
+ 1;
}
/**
* Show the autocomplete.
* @param {boolean} isInput Whether triggered by input.
* @param {boolean} isForced Whether force-showing (ctrl+space).
* @param {boolean} isSelect Whether an autocomplete option was just selected.
*/
async show(isInput = false, isForced = false, isSelect = false) {
//TODO check if isInput and isForced are both required
this.text = this.textarea.value;
this.isReplaceable = false;
if (document.activeElement != this.textarea) {
// only show with textarea in focus
return this.hide();
}
if (!this.checkIfActivate()) {
// only show if provider wants to
return this.hide();
}
// disable force-hide if trigger was forced
if (isForced) this.isForceHidden = false;
// request provider to get name result (potentially "incomplete", i.e. not an actual existing name) for
// cursor position
this.parserResult = await this.getNameAt(this.text, this.textarea.selectionStart);
this.secondaryParserResult = null;
if (!this.parserResult) {
// don't show if no name result found, e.g., cursor's area is not a command
return this.hide();
}
// need to know if name can be inside quotes, and then check if quotes are already there
if (this.parserResult.canBeQuoted) {
this.startQuote = this.text[this.parserResult.start] == '"';
this.endQuote = this.startQuote && this.text[this.parserResult.start + this.parserResult.name.length + 1] == '"';
} else {
this.startQuote = false;
this.endQuote = false;
}
// use lowercase name for matching
this.name = this.parserResult.name.toLowerCase() ?? '';
const isCursorInNamePart = this.textarea.selectionStart >= this.parserResult.start && this.textarea.selectionStart <= this.parserResult.start + this.parserResult.name.length + (this.startQuote ? 1 : 0);
if (isForced || isInput) {
// if forced (ctrl+space) or user input...
if (isCursorInNamePart) {
// ...and cursor is somewhere in the name part (including right behind the final char)
// -> show autocomplete for the (partial if cursor in the middle) name
this.name = this.name.slice(0, this.textarea.selectionStart - (this.parserResult.start) - (this.startQuote ? 1 : 0));
this.parserResult.name = this.name;
this.isReplaceable = true;
this.isForceHidden = false;
this.canBeAutoHidden = false;
} else {
this.isReplaceable = false;
this.canBeAutoHidden = this.basicAutoHideCheck();
}
} else {
// if not forced and no user input -> just show details
this.isReplaceable = false;
this.canBeAutoHidden = this.basicAutoHideCheck();
}
if (isForced || isInput || isSelect) {
// is forced or user input or just selected autocomplete option...
if (!isCursorInNamePart) {
// ...and cursor is not somwehere in the main name part -> check for secondary options (e.g., named arguments)
const result = this.parserResult.getSecondaryNameAt(this.text, this.textarea.selectionStart, isSelect);
if (result && (isForced || result.isRequired)) {
this.secondaryParserResult = result;
this.name = this.secondaryParserResult.name;
this.isReplaceable = isForced || this.secondaryParserResult.isRequired;
this.isForceHidden = false;
this.canBeAutoHidden = false;
} else {
this.isReplaceable = false;
this.canBeAutoHidden = this.basicAutoHideCheck();
}
}
}
if (this.matchType == 'fuzzy') {
// only build the fuzzy regex if match type is set to fuzzy
this.fuzzyRegex = new RegExp(`^(.*?)${this.name.split('').map(char => `(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i');
}
//TODO maybe move the matchers somewhere else; a single match function? matchType is available as property
const matchers = {
'strict': (name) => name.toLowerCase().startsWith(this.name),
'includes': (name) => name.toLowerCase().includes(this.name),
'fuzzy': (name) => this.fuzzyRegex.test(name),
};
this.result = this.effectiveParserResult.optionList
// filter the list of options by the partial name according to the matching type
.filter(it => this.isReplaceable || it.name == '' ? (it.matchProvider ? it.matchProvider(this.name) : matchers[this.matchType](it.name)) : it.name.toLowerCase() == this.name)
// remove aliases
.filter((it, idx, list) => list.findIndex(opt => opt.value == it.value) == idx);
if (this.result.length == 0 && this.effectiveParserResult != this.parserResult && isForced) {
// no matching secondary results and forced trigger -> show current command details
this.secondaryParserResult = null;
this.result = [this.effectiveParserResult.optionList.find(it => it.name == this.effectiveParserResult.name)];
this.name = this.effectiveParserResult.name;
this.fuzzyRegex = /(.*)(.*)(.*)/;
}
this.result = this.result
// update remaining options
.map(option => {
// build element
option.dom = this.makeItem(option);
// update replacer and add quotes if necessary
const optionName = option.valueProvider ? option.valueProvider(this.name) : option.name;
if (this.effectiveParserResult.canBeQuoted) {
option.replacer = optionName.includes(' ') || this.startQuote || this.endQuote ? `"${optionName.replace(/"/g, '\\"')}"` : `${optionName}`;
} else {
option.replacer = optionName;
}
// calculate fuzzy score if matching is fuzzy
if (this.matchType == 'fuzzy') this.fuzzyScore(option);
// update the name to highlight the matched chars
this.updateName(option);
return option;
})
// sort by fuzzy score or alphabetical
.toSorted(this.matchType == 'fuzzy' ? this.fuzzyScoreCompare : (a, b) => a.name.localeCompare(b.name));
if (this.isForceHidden) {
// hidden with escape
return this.hide();
}
if (this.autoHide && this.canBeAutoHidden && !isForced && this.effectiveParserResult == this.parserResult && this.result.length == 1) {
// auto hide user setting enabled and somewhere after name part and would usually show command details
return this.hide();
}
if (this.result.length == 0) {
if (!isInput) {
// no result and no input? hide autocomplete
return this.hide();
}
if (this.effectiveParserResult instanceof AutoCompleteSecondaryNameResult && !this.effectiveParserResult.forceMatch) {
// no result and matching is no forced? hide autocomplete
return this.hide();
}
// otherwise add "no match" notice
const option = new BlankAutoCompleteOption(
this.name.length ?
this.effectiveParserResult.makeNoMatchText()
: this.effectiveParserResult.makeNoOptionsText()
,
);
this.result.push(option);
} else if (this.result.length == 1 && this.effectiveParserResult && this.effectiveParserResult != this.secondaryParserResult && this.result[0].name == this.effectiveParserResult.name) {
// only one result that is exactly the current value? just show hint, no autocomplete
this.isReplaceable = false;
this.isShowingDetails = false;
} else if (!this.isReplaceable && this.result.length > 1) {
return this.hide();
}
this.selectedItem = this.result[0];
this.isActive = true;
this.wasForced = isForced;
this.renderDebounced();
}
/**
* Hide autocomplete.
*/
hide() {
this.domWrap?.remove();
this.detailsWrap?.remove();
this.isActive = false;
this.isShowingDetails = false;
this.wasForced = false;
}
/**
* Create updated DOM.
*/
render() {
if (!this.isActive) return this.domWrap.remove();
if (this.isReplaceable) {
this.dom.innerHTML = '';
const frag = document.createDocumentFragment();
for (const item of this.result) {
if (item == this.selectedItem) {
item.dom.classList.add('selected');
} else {
item.dom.classList.remove('selected');
}
if (!item.isSelectable) {
item.dom.classList.add('not-selectable');
}
frag.append(item.dom);
}
this.dom.append(frag);
this.updatePosition();
this.getLayer().append(this.domWrap);
} else {
this.domWrap.remove();
}
this.renderDetailsDebounced();
}
/**
* Create updated DOM for details.
*/
renderDetails() {
if (!this.isActive) return this.detailsWrap.remove();
if (!this.isShowingDetails && this.isReplaceable) return this.detailsWrap.remove();
this.detailsDom.innerHTML = '';
this.detailsDom.append(this.selectedItem?.renderDetails() ?? 'NO ITEM');
this.getLayer().append(this.detailsWrap);
this.updateDetailsPositionDebounced();
}
/**
* @returns {HTMLElement} closest ancestor dialog or body
*/
getLayer() {
return this.textarea.closest('dialog, body');
}
/**
* Update position of DOM.
*/
updatePosition() {
if (this.isFloating) {
this.updateFloatingPosition();
} else {
const rect = {};
rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.FULL] = this.getLayer().getBoundingClientRect();
this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
this.dom.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
this.domWrap.style.bottom = `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`;
if (this.isShowingDetails) {
this.domWrap.style.setProperty('--leftOffset', '1vw');
this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`);
this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(${rect[power_user.stscript.autocomplete.width.right].right}px, ${this.isShowingDetails ? 74 : 0}vw)`);
} else {
this.domWrap.style.setProperty('--leftOffset', `max(1vw, ${rect[power_user.stscript.autocomplete.width.left].left}px)`);
this.domWrap.style.setProperty('--rightOffset', `calc(100vw - min(99vw, ${rect[power_user.stscript.autocomplete.width.right].right}px)`);
}
}
this.updateDetailsPosition();
}
/**
* Update position of details DOM.
*/
updateDetailsPosition() {
if (this.isShowingDetails || !this.isReplaceable) {
if (this.isFloating) {
this.updateFloatingDetailsPosition();
} else {
const rect = {};
rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.FULL] = this.getLayer().getBoundingClientRect();
if (this.isReplaceable) {
this.detailsWrap.classList.remove('full');
const selRect = this.selectedItem.dom.children[0].getBoundingClientRect();
this.detailsWrap.style.setProperty('--targetOffset', `${selRect.top}`);
this.detailsWrap.style.setProperty('--rightOffset', '1vw');
this.detailsWrap.style.setProperty('--bottomOffset', `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT].top}px)`);
this.detailsWrap.style.setProperty('--leftOffset', `calc(100vw - ${this.domWrap.style.getPropertyValue('--rightOffset')}`);
} else {
this.detailsWrap.classList.add('full');
this.detailsWrap.style.setProperty('--targetOffset', `${rect[AUTOCOMPLETE_WIDTH.INPUT].top}`);
this.detailsWrap.style.setProperty('--bottomOffset', `calc(100vh - ${rect[AUTOCOMPLETE_WIDTH.INPUT].top}px)`);
this.detailsWrap.style.setProperty('--leftOffset', `${rect[power_user.stscript.autocomplete.width.left].left}px`);
this.detailsWrap.style.setProperty('--rightOffset', `calc(100vw - ${rect[power_user.stscript.autocomplete.width.right].right}px)`);
}
}
}
}
/**
* Update position of floating autocomplete.
*/
updateFloatingPosition() {
const location = this.getCursorPosition();
const rect = this.textarea.getBoundingClientRect();
const layerRect = this.getLayer().getBoundingClientRect();
// cursor is out of view -> hide
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
return this.hide();
}
const left = Math.max(rect.left, location.left) - layerRect.left;
this.domWrap.style.setProperty('--targetOffset', `${left}`);
if (location.top <= window.innerHeight / 2) {
// if cursor is in lower half of window, show list above line
this.domWrap.style.top = `${location.bottom - layerRect.top}px`;
this.domWrap.style.bottom = 'auto';
this.domWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
} else {
// if cursor is in upper half of window, show list below line
this.domWrap.style.top = 'auto';
this.domWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`;
this.domWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
}
}
updateFloatingDetailsPosition(location = null) {
if (!location) location = this.getCursorPosition();
const rect = this.textarea.getBoundingClientRect();
const layerRect = this.getLayer().getBoundingClientRect();
if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
return this.hide();
}
const left = Math.max(rect.left, location.left) - layerRect.left;
this.detailsWrap.style.setProperty('--targetOffset', `${left}`);
if (this.isReplaceable) {
this.detailsWrap.classList.remove('full');
if (left < window.innerWidth / 4) {
// if cursor is in left part of screen, show details on right of list
this.detailsWrap.classList.add('right');
this.detailsWrap.classList.remove('left');
} else {
// if cursor is in right part of screen, show details on left of list
this.detailsWrap.classList.remove('right');
this.detailsWrap.classList.add('left');
}
} else {
this.detailsWrap.classList.remove('left');
this.detailsWrap.classList.remove('right');
this.detailsWrap.classList.add('full');
}
if (location.top <= window.innerHeight / 2) {
// if cursor is in lower half of window, show list above line
this.detailsWrap.style.top = `${location.bottom - layerRect.top}px`;
this.detailsWrap.style.bottom = 'auto';
this.detailsWrap.style.maxHeight = `calc(${location.bottom - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
} else {
// if cursor is in upper half of window, show list below line
this.detailsWrap.style.top = 'auto';
this.detailsWrap.style.bottom = `calc(${layerRect.height}px - ${location.top - layerRect.top}px)`;
this.detailsWrap.style.maxHeight = `calc(${location.top - layerRect.top}px - ${this.textarea.closest('dialog') ? '0' : '1vh'})`;
}
}
/**
* Calculate (keyboard) cursor coordinates within textarea.
* @returns {{left:number, top:number, bottom:number}}
*/
getCursorPosition() {
const inputRect = this.textarea.getBoundingClientRect();
const style = window.getComputedStyle(this.textarea);
if (!this.clone) {
this.clone = document.createElement('div');
for (const key of style) {
this.clone.style[key] = style[key];
}
this.clone.style.position = 'fixed';
this.clone.style.visibility = 'hidden';
document.body.append(this.clone);
const mo = new MutationObserver(muts => {
if (muts.find(it => Array.from(it.removedNodes).includes(this.textarea))) {
this.clone.remove();
}
});
mo.observe(this.textarea.parentElement, { childList: true });
}
this.clone.style.height = `${inputRect.height}px`;
this.clone.style.left = `${inputRect.left}px`;
this.clone.style.top = `${inputRect.top}px`;
this.clone.style.whiteSpace = style.whiteSpace;
this.clone.style.tabSize = style.tabSize;
const text = this.textarea.value;
const before = text.slice(0, this.textarea.selectionStart);
this.clone.textContent = before;
const locator = document.createElement('span');
locator.textContent = text[this.textarea.selectionStart];
this.clone.append(locator);
this.clone.append(text.slice(this.textarea.selectionStart + 1));
this.clone.scrollTop = this.textarea.scrollTop;
this.clone.scrollLeft = this.textarea.scrollLeft;
const locatorRect = locator.getBoundingClientRect();
const location = {
left: locatorRect.left,
top: locatorRect.top,
bottom: locatorRect.bottom,
};
return location;
}
/**
* Toggle details view alongside autocomplete list.
*/
toggleDetails() {
this.isShowingDetails = !this.isShowingDetails;
this.renderDetailsDebounced();
this.updatePosition();
}
/**
* Select an item for autocomplete and put text into textarea.
*/
async select() {
if (this.isReplaceable && this.selectedItem.value !== null) {
this.textarea.value = `${this.text.slice(0, this.effectiveParserResult.start)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`;
this.textarea.selectionStart = this.effectiveParserResult.start + this.selectedItem.replacer.length;
this.textarea.selectionEnd = this.textarea.selectionStart;
this.show(false, false, true);
} else {
const selectionStart = this.textarea.selectionStart;
const selectionEnd = this.textarea.selectionDirection;
this.textarea.selectionStart = selectionStart;
this.textarea.selectionDirection = selectionEnd;
}
this.wasForced = false;
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
this.onSelect?.(this.selectedItem);
}
/**
* Mark the item at newIdx in the autocomplete list as selected.
* @param {number} newIdx
*/
selectItemAtIndex(newIdx) {
this.selectedItem.dom.classList.remove('selected');
this.selectedItem = this.result[newIdx];
this.selectedItem.dom.classList.add('selected');
const rect = this.selectedItem.dom.children[0].getBoundingClientRect();
const rectParent = this.dom.getBoundingClientRect();
if (rect.top < rectParent.top || rect.bottom > rectParent.bottom) {
this.dom.scrollTop += rect.top < rectParent.top ? rect.top - rectParent.top : rect.bottom - rectParent.bottom;
}
this.renderDetailsDebounced();
}
/**
* Handle keyboard events.
* @param {KeyboardEvent} evt The event.
*/
async handleKeyDown(evt) {
// autocomplete is shown and cursor at end of current command name (or inside name and typed or forced)
if (this.isActive && this.isReplaceable) {
// actions in the list
switch (evt.key) {
case 'ArrowUp': {
// select previous item
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
evt.preventDefault();
evt.stopPropagation();
const idx = this.result.indexOf(this.selectedItem);
let newIdx;
if (idx == 0) newIdx = this.result.length - 1;
else newIdx = idx - 1;
this.selectItemAtIndex(newIdx);
return;
}
case 'ArrowDown': {
// select next item
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
evt.preventDefault();
evt.stopPropagation();
const idx = this.result.indexOf(this.selectedItem);
const newIdx = (idx + 1) % this.result.length;
this.selectItemAtIndex(newIdx);
return;
}
case 'Enter': {
// pick the selected item to autocomplete
if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.ENTER) != AUTOCOMPLETE_SELECT_KEY.ENTER) break;
if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break;
if (this.selectedItem.name == this.name) break;
if (!this.selectedItem.isSelectable) break;
evt.preventDefault();
evt.stopImmediatePropagation();
this.select();
return;
}
case 'Tab': {
// pick the selected item to autocomplete
if ((power_user.stscript.autocomplete.select & AUTOCOMPLETE_SELECT_KEY.TAB) != AUTOCOMPLETE_SELECT_KEY.TAB) break;
if (evt.ctrlKey || evt.altKey || evt.shiftKey || this.selectedItem.value == '') break;
evt.preventDefault();
evt.stopImmediatePropagation();
if (!this.selectedItem.isSelectable) break;
this.select();
return;
}
}
}
// details are shown, cursor can be anywhere
if (this.isActive) {
switch (evt.key) {
case 'Escape': {
// close autocomplete
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
evt.preventDefault();
evt.stopPropagation();
this.isForceHidden = true;
this.wasForced = false;
this.hide();
return;
}
case 'Enter': {
// hide autocomplete on enter (send, execute, ...)
if (!evt.shiftKey) {
this.hide();
return;
}
break;
}
}
}
// autocomplete shown or not, cursor anywhere
switch (evt.key) {
// The first is a non-breaking space, the second is a regular space.
case ' ':
case ' ': {
if (evt.ctrlKey || evt.altKey) {
if (this.isActive && this.isReplaceable) {
// ctrl-space to toggle details for selected item
this.toggleDetails();
} else {
// ctrl-space to force show autocomplete
this.show(false, true);
}
evt.preventDefault();
evt.stopPropagation();
return;
}
break;
}
}
if (['Control', 'Shift', 'Alt'].includes(evt.key)) {
// ignore keydown on modifier keys
return;
}
// await keyup to see if cursor position or text has changed
const oldText = this.textarea.value;
await new Promise(resolve => {
window.addEventListener('keyup', resolve, { once: true });
});
if (this.selectionStart != this.textarea.selectionStart) {
this.selectionStart = this.textarea.selectionStart;
this.show(this.isReplaceable || oldText != this.textarea.value);
} else if (this.isActive) {
this.text != this.textarea.value && this.show(this.isReplaceable);
}
}
}

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,260 @@
/**
* Enhanced macro autocomplete option for the new MacroRegistry-based system.
* Reuses rendering logic from MacroBrowser for consistency and DRY.
*/
import { AutoCompleteOption } from './AutoCompleteOption.js';
import {
formatMacroSignature,
createSourceIndicator,
createAliasIndicator,
renderMacroDetails,
} from '../macros/MacroBrowser.js';
import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
/**
* Macro context passed from the parser to provide cursor position info.
* @typedef {Object} MacroAutoCompleteContext
* @property {string} fullText - The full macro text being typed (without {{ }}).
* @property {number} cursorOffset - Cursor position within the macro text.
* @property {string} identifier - The macro identifier (name).
* @property {string[]} args - Array of arguments typed so far.
* @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
*/
export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
/** @type {MacroDefinition} */
#macro;
/** @type {MacroAutoCompleteContext|null} */
#context = null;
/**
* @param {MacroDefinition} macro - The macro definition from MacroRegistry.
* @param {MacroAutoCompleteContext} [context] - Optional context for argument hints.
*/
constructor(macro, context = null) {
// Use the macro name as the autocomplete key
super(macro.name, enumIcons.macro);
this.#macro = macro;
this.#context = context;
// nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)
this.nameOffset = 2;
}
/** @returns {MacroDefinition} */
get macro() {
return this.#macro;
}
/**
* Renders the list item for the autocomplete dropdown.
* Tight display: [icon] [signature] [description] [alias icon?] [source icon]
* @returns {HTMLElement}
*/
renderItem() {
const li = document.createElement('li');
li.classList.add('item', 'macro-ac-item');
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'macro');
// Type icon
const type = document.createElement('span');
type.classList.add('type', 'monospace');
type.textContent = '{}';
li.append(type);
// Specs container (for fuzzy highlight compatibility)
const specs = document.createElement('span');
specs.classList.add('specs');
// Name with character spans for fuzzy highlighting
const nameEl = document.createElement('span');
nameEl.classList.add('name', 'monospace');
// Build signature with individual character spans (includes {{ }})
const sigText = formatMacroSignature(this.#macro);
for (const char of sigText) {
const span = document.createElement('span');
span.textContent = char;
nameEl.append(span);
}
specs.append(nameEl);
li.append(specs);
// Stopgap (spacer for flex layout)
const stopgap = document.createElement('span');
stopgap.classList.add('stopgap');
li.append(stopgap);
// Help text (description)
const help = document.createElement('span');
help.classList.add('help');
const content = document.createElement('span');
content.classList.add('helpContent');
content.textContent = this.#macro.description || '';
help.append(content);
li.append(help);
// Alias indicator icon (if this is an alias)
const aliasIcon = createAliasIndicator(this.#macro);
if (aliasIcon) {
aliasIcon.classList.add('macro-ac-indicator');
li.append(aliasIcon);
}
// Source indicator icon
const sourceIcon = createSourceIndicator(this.#macro);
sourceIcon.classList.add('macro-ac-indicator');
li.append(sourceIcon);
return li;
}
/**
* Renders the details panel content.
* Reuses renderMacroDetails from MacroBrowser with autocomplete-specific options.
* @returns {DocumentFragment}
*/
renderDetails() {
const frag = document.createDocumentFragment();
// Determine current argument index for highlighting
const currentArgIndex = this.#context?.currentArgIndex ?? -1;
// Render argument hint banner if we're typing an argument
if (currentArgIndex >= 0) {
const hint = this.#renderArgumentHint();
if (hint) frag.append(hint);
}
// Reuse MacroBrowser's renderMacroDetails with options
const details = renderMacroDetails(this.#macro, { currentArgIndex });
// Add class for autocomplete-specific styling overrides
details.classList.add('macro-ac-details');
frag.append(details);
return frag;
}
/**
* Renders the current argument hint banner.
* @returns {HTMLElement|null}
*/
#renderArgumentHint() {
if (!this.#context || this.#context.currentArgIndex < 0) return null;
const argIndex = this.#context.currentArgIndex;
const isListArg = argIndex >= this.#macro.maxArgs;
// If we're beyond unnamed args and there's no list, no hint
if (isListArg && !this.#macro.list) return null;
const hint = document.createElement('div');
hint.classList.add('macro-ac-arg-hint');
const icon = document.createElement('i');
icon.classList.add('fa-solid', 'fa-arrow-right');
hint.append(icon);
if (isListArg) {
// List argument hint
const listIndex = argIndex - this.#macro.maxArgs + 1;
const text = document.createElement('span');
text.innerHTML = `<strong>List item ${listIndex}</strong>`;
hint.append(text);
} else {
// Unnamed argument hint (required or optional)
const argDef = this.#macro.unnamedArgDefs[argIndex];
let optionalLabel = '';
if (argDef?.optional) {
optionalLabel = argDef.defaultValue !== undefined
? ` <em>(optional, default: ${argDef.defaultValue === '' ? '<empty string>' : argDef.defaultValue})</em>`
: ' <em>(optional)</em>';
}
const text = document.createElement('span');
text.innerHTML = `<strong>${argDef?.name || `Argument ${argIndex + 1}`}</strong>${optionalLabel}`;
if (argDef?.type) {
const typeSpan = document.createElement('code');
typeSpan.classList.add('macro-ac-hint-type');
if (Array.isArray(argDef.type)) {
typeSpan.textContent = argDef.type.join(' | ');
typeSpan.title = `Accepts: ${argDef.type.join(', ')}`;
} else {
typeSpan.textContent = argDef.type;
}
text.append(' ', typeSpan);
}
hint.append(text);
if (argDef?.description) {
const descSpan = document.createElement('span');
descSpan.classList.add('macro-ac-hint-desc');
descSpan.textContent = `${argDef.description}`;
hint.append(descSpan);
}
if (argDef?.sampleValue) {
const sampleSpan = document.createElement('span');
sampleSpan.classList.add('macro-ac-hint-sample');
sampleSpan.textContent = ` (e.g. ${argDef.sampleValue})`;
hint.append(sampleSpan);
}
}
return hint;
}
}
/**
* Parses the macro text to determine current argument context.
* @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "random::a::b".
* @param {number} cursorOffset - Cursor position within macroText.
* @returns {MacroAutoCompleteContext}
*/
export function parseMacroContext(macroText, cursorOffset) {
const parts = [];
let currentPart = '';
let partStart = 0;
let i = 0;
while (i < macroText.length) {
if (macroText[i] === ':' && macroText[i + 1] === ':') {
parts.push({ text: currentPart, start: partStart, end: i });
currentPart = '';
i += 2;
partStart = i;
} else {
currentPart += macroText[i];
i++;
}
}
// Push the last part
parts.push({ text: currentPart, start: partStart, end: macroText.length });
// Determine which part the cursor is in
let currentArgIndex = -1;
for (let idx = 0; idx < parts.length; idx++) {
const part = parts[idx];
if (cursorOffset >= part.start && cursorOffset <= part.end) {
currentArgIndex = idx - 1; // -1 because first part is identifier
break;
}
}
// If cursor is after all parts (at the end), we're in the last arg
if (currentArgIndex === -1 && cursorOffset >= parts[parts.length - 1].end) {
currentArgIndex = parts.length - 1;
}
return {
fullText: macroText,
cursorOffset,
identifier: parts[0]?.text.trim() || '',
args: parts.slice(1).map(p => p.text),
currentArgIndex,
};
}

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,973 @@
import { Fuse, localforage } from '../lib.js';
import { characters, chat_metadata, eventSource, event_types, generateQuietPrompt, getCurrentChatId, getRequestHeaders, getThumbnailUrl, saveMetadata, saveSettingsDebounced, this_chid } from '../script.js';
import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension } from './utils.js';
import { debounce_timeout } from './constants.js';
import { t } from './i18n.js';
import { Popup } from './popup.js';
import { groups, selected_group } from './group-chats.js';
import { humanizedDateTime } from './RossAscends-mods.js';
import { deleteMediaFromServer } from './chats.js';
const BG_METADATA_KEY = 'custom_background';
const LIST_METADATA_KEY = 'chat_backgrounds';
// A single transparent PNG pixel used as a placeholder for errored backgrounds
const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`;
const THUMBNAIL_COLUMNS_MIN = 2;
const THUMBNAIL_COLUMNS_MAX = 8;
const THUMBNAIL_COLUMNS_DEFAULT_DESKTOP = 5;
const THUMBNAIL_COLUMNS_DEFAULT_MOBILE = 3;
/**
* Storage for frontend-generated background thumbnails.
* This is used to store thumbnails for backgrounds that cannot be generated on the server.
*/
const THUMBNAIL_STORAGE = localforage.createInstance({ name: 'SillyTavern_Thumbnails' });
/**
* Cache for thumbnail blob URLs.
* @type {Map<string, string>}
*/
const THUMBNAIL_BLOBS = new Map();
const THUMBNAIL_CONFIG = {
width: 160,
height: 90,
};
/**
* Background source types.
* @readonly
* @enum {number}
*/
const BG_SOURCES = {
GLOBAL: 0,
CHAT: 1,
};
/**
* Mapping of background sources to their corresponding tab IDs.
* @readonly
* @type {Record<string, string>}
*/
const BG_TABS = Object.freeze({
[BG_SOURCES.GLOBAL]: 'bg_global_tab',
[BG_SOURCES.CHAT]: 'bg_chat_tab',
});
/**
* Global IntersectionObserver instance for lazy loading backgrounds
* @type {IntersectionObserver|null}
*/
let lazyLoadObserver = null;
export let background_settings = {
name: '__transparent.png',
url: generateUrlParameter('__transparent.png', false),
fitting: 'classic',
animation: false,
};
/**
* Creates a single thumbnail DOM element. The CSS now handles all sizing.
* @param {object} imageData - Data for the image (filename, isCustom).
* @returns {HTMLElement} The created thumbnail element.
*/
function createThumbnailElement(imageData) {
const bg = imageData.filename;
const isCustom = imageData.isCustom;
const thumbnail = $('#background_template .bg_example').clone();
const clipper = document.createElement('div');
clipper.className = 'thumbnail-clipper lazy-load-background';
clipper.style.backgroundImage = PLACEHOLDER_IMAGE;
const titleElement = thumbnail.find('.BGSampleTitle');
clipper.appendChild(titleElement.get(0));
thumbnail.append(clipper);
const url = generateUrlParameter(bg, isCustom);
const title = isCustom ? bg.split('/').pop() : bg;
const friendlyTitle = title.slice(0, title.lastIndexOf('.'));
thumbnail.attr('title', title);
thumbnail.attr('bgfile', bg);
thumbnail.attr('custom', String(isCustom));
thumbnail.data('url', url);
titleElement.text(friendlyTitle);
return thumbnail.get(0);
}
/**
* Applies the thumbnail column count to the CSS and updates button states.
* @param {number} count - The number of columns to display.
*/
function applyThumbnailColumns(count) {
const newCount = Math.max(THUMBNAIL_COLUMNS_MIN, Math.min(count, THUMBNAIL_COLUMNS_MAX));
background_settings.thumbnailColumns = newCount;
document.documentElement.style.setProperty('--bg-thumb-columns', newCount.toString());
$('#bg_thumb_zoom_in').prop('disabled', newCount <= THUMBNAIL_COLUMNS_MIN);
$('#bg_thumb_zoom_out').prop('disabled', newCount >= THUMBNAIL_COLUMNS_MAX);
saveSettingsDebounced();
}
export function loadBackgroundSettings(settings) {
let backgroundSettings = settings.background;
if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {
backgroundSettings = background_settings;
}
if (!backgroundSettings.fitting) {
backgroundSettings.fitting = 'classic';
}
if (!Object.hasOwn(backgroundSettings, 'animation')) {
backgroundSettings.animation = false;
}
// If a value is already saved, use it. Otherwise, determine default based on screen size.
let columns = backgroundSettings.thumbnailColumns;
if (!columns) {
const isNarrowScreen = window.matchMedia('(max-width: 480px)').matches;
columns = isNarrowScreen ? THUMBNAIL_COLUMNS_DEFAULT_MOBILE : THUMBNAIL_COLUMNS_DEFAULT_DESKTOP;
}
background_settings.thumbnailColumns = columns;
applyThumbnailColumns(background_settings.thumbnailColumns);
setBackground(backgroundSettings.name, backgroundSettings.url);
setFittingClass(backgroundSettings.fitting);
$('#background_fitting').val(backgroundSettings.fitting);
$('#background_thumbnails_animation').prop('checked', background_settings.animation);
highlightSelectedBackground();
}
/**
* Sets the background for the current chat and adds it to the list of custom backgrounds.
* @param {{url: string, path:string}} backgroundInfo
*/
async function forceSetBackground(backgroundInfo) {
saveBackgroundMetadata(backgroundInfo.url);
$('#bg1').css('background-image', backgroundInfo.url);
const list = chat_metadata[LIST_METADATA_KEY] || [];
const bg = backgroundInfo.path;
list.push(bg);
chat_metadata[LIST_METADATA_KEY] = list;
saveMetadataDebounced();
renderChatBackgrounds();
highlightNewBackground(bg);
highlightLockedBackground();
}
async function onChatChanged() {
const lockedUrl = chat_metadata[BG_METADATA_KEY];
$('#bg1').css('background-image', lockedUrl || background_settings.url);
renderChatBackgrounds();
highlightLockedBackground();
highlightSelectedBackground();
}
function getBackgroundPath(fileUrl) {
return `backgrounds/${encodeURIComponent(fileUrl)}`;
}
function highlightLockedBackground() {
$('.bg_example.locked-background').removeClass('locked-background');
const lockedBackgroundUrl = chat_metadata[BG_METADATA_KEY];
if (lockedBackgroundUrl) {
$('.bg_example').filter(function () {
return $(this).data('url') === lockedBackgroundUrl;
}).addClass('locked-background');
}
}
/**
* Locks the background for the current chat
* @param {Event|null} event
*/
function onLockBackgroundClick(event = null) {
if (!getCurrentChatId()) {
toastr.warning(t`Select a chat to lock the background for it`);
return;
}
// Take the global background's URL and save it to the chat's metadata.
const urlToLock = event ? $(event.target).closest('.bg_example').data('url') : background_settings.url;
saveBackgroundMetadata(urlToLock);
$('#bg1').css('background-image', urlToLock);
// Update UI states to reflect the new lock.
highlightLockedBackground();
highlightSelectedBackground();
}
/**
* Unlocks the background for the current chat
* @param {Event|null} _event
*/
function onUnlockBackgroundClick(_event = null) {
// Delete the lock from the chat's metadata.
removeBackgroundMetadata();
// Revert the view to the current global background.
$('#bg1').css('background-image', background_settings.url);
// Update UI states to reflect the removal of the lock.
highlightLockedBackground();
highlightSelectedBackground();
}
function isChatBackgroundLocked() {
return chat_metadata[BG_METADATA_KEY];
}
function saveBackgroundMetadata(file) {
chat_metadata[BG_METADATA_KEY] = file;
saveMetadataDebounced();
}
function removeBackgroundMetadata() {
delete chat_metadata[BG_METADATA_KEY];
saveMetadataDebounced();
}
/**
* Handles the click event for selecting a background.
* @param {JQuery.Event} e Event
*/
function onSelectBackgroundClick(e) {
const bgFile = $(this).attr('bgfile');
const isCustom = $(this).attr('custom') === 'true';
const backgroundCssUrl = getUrlParameter(this);
const bypassGlobalLock = !isCustom && e.shiftKey;
if ((isChatBackgroundLocked() || isCustom) && !bypassGlobalLock) {
// If a background is locked, update the locked background directly
saveBackgroundMetadata(backgroundCssUrl);
$('#bg1').css('background-image', backgroundCssUrl);
} else {
// Otherwise, update the global background setting
setBackground(bgFile, backgroundCssUrl);
}
// Update UI highlights to reflect the changes.
highlightLockedBackground();
highlightSelectedBackground();
}
async function onCopyToSystemBackgroundClick(e) {
e.stopPropagation();
const bgNames = await getNewBackgroundName(this);
if (!bgNames) {
return;
}
const bgFile = await fetch(bgNames.oldBg);
if (!bgFile.ok) {
toastr.warning('Failed to copy background');
return;
}
const blob = await bgFile.blob();
const file = new File([blob], bgNames.newBg);
const formData = new FormData();
formData.set('avatar', file);
await uploadBackground(formData);
const list = chat_metadata[LIST_METADATA_KEY] || [];
const index = list.indexOf(bgNames.oldBg);
list.splice(index, 1);
saveMetadataDebounced();
renderChatBackgrounds();
}
/**
* Gets a thumbnail for the background from storage or fetches it if not available.
* It caches the thumbnail in local storage and returns a blob URL for the thumbnail.
* If the thumbnail cannot be fetched, it returns a transparent PNG pixel as a fallback.
* @param {string} bg Background URL
* @param {boolean} isCustom Is the background custom?
* @returns {Promise<string>} Blob URL of the thumbnail
*/
async function getThumbnailFromStorage(bg, isCustom) {
const cachedBlobUrl = THUMBNAIL_BLOBS.get(bg);
if (cachedBlobUrl) {
return cachedBlobUrl;
}
const savedBlob = await THUMBNAIL_STORAGE.getItem(bg);
if (savedBlob) {
const savedBlobUrl = URL.createObjectURL(savedBlob);
THUMBNAIL_BLOBS.set(bg, savedBlobUrl);
return savedBlobUrl;
}
try {
const url = isCustom ? bg : getBackgroundPath(bg);
const response = await fetch(url, { cache: 'force-cache' });
if (!response.ok) {
throw new Error('Fetch failed with status: ' + response.status);
}
const imageBlob = await response.blob();
const imageBase64 = await getBase64Async(imageBlob);
const thumbnailBase64 = await createThumbnail(imageBase64, THUMBNAIL_CONFIG.width, THUMBNAIL_CONFIG.height);
const thumbnailBlob = await fetch(thumbnailBase64).then(res => res.blob());
await THUMBNAIL_STORAGE.setItem(bg, thumbnailBlob);
const blobUrl = URL.createObjectURL(thumbnailBlob);
THUMBNAIL_BLOBS.set(bg, blobUrl);
return blobUrl;
} catch (error) {
console.error('Error fetching thumbnail, fallback image will be used:', error);
const fallbackBlob = PNG_PIXEL_BLOB;
const fallbackBlobUrl = URL.createObjectURL(fallbackBlob);
THUMBNAIL_BLOBS.set(bg, fallbackBlobUrl);
return fallbackBlobUrl;
}
}
/**
* Gets the new background name from the user.
* @param {Element} referenceElement
* @returns {Promise<{oldBg: string, newBg: string}>}
* */
async function getNewBackgroundName(referenceElement) {
const exampleBlock = $(referenceElement).closest('.bg_example');
const isCustom = exampleBlock.attr('custom') === 'true';
const oldBg = exampleBlock.attr('bgfile');
if (!oldBg) {
console.debug('no bgfile');
return;
}
const fileExtension = oldBg.split('.').pop();
const fileNameBase = isCustom ? oldBg.split('/').pop() : oldBg;
const oldBgExtensionless = fileNameBase.replace(`.${fileExtension}`, '');
const newBgExtensionless = await Popup.show.input(t`Enter new background name:`, null, oldBgExtensionless);
if (!newBgExtensionless) {
console.debug('no new_bg_extensionless');
return;
}
const newBg = `${newBgExtensionless}.${fileExtension}`;
if (oldBgExtensionless === newBgExtensionless) {
console.debug('new_bg === old_bg');
return;
}
return { oldBg, newBg };
}
async function onRenameBackgroundClick(e) {
e.stopPropagation();
const bgNames = await getNewBackgroundName(this);
if (!bgNames) {
return;
}
const data = { old_bg: bgNames.oldBg, new_bg: bgNames.newBg };
const response = await fetch('/api/backgrounds/rename', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(data),
cache: 'no-cache',
});
if (response.ok) {
await getBackgrounds();
highlightNewBackground(bgNames.newBg);
} else {
toastr.warning('Failed to rename background');
}
}
async function onDeleteBackgroundClick(e) {
e.stopPropagation();
const bgToDelete = $(this).closest('.bg_example');
const url = bgToDelete.data('url');
const isCustom = bgToDelete.attr('custom') === 'true';
const deleteFromServerId = 'delete_bg_from_server';
const customInputs = [
{
type: 'checkbox',
label: t`Also delete file from server`,
id: deleteFromServerId,
defaultState: true,
},
];
let deleteFromServer = false;
const confirm = await Popup.show.confirm(t`Delete the background?`, null, {
customInputs: isCustom ? customInputs : [],
onClose: (popup) => {
if (isCustom) {
deleteFromServer = Boolean(popup?.inputResults?.get(deleteFromServerId) ?? false);
}
},
});
const bg = bgToDelete.attr('bgfile');
if (confirm) {
// If it's not custom, it's a built-in background. Delete it from the server
if (!isCustom) {
await delBackground(bg);
} else {
const list = chat_metadata[LIST_METADATA_KEY] || [];
const index = list.indexOf(bg);
list.splice(index, 1);
}
if (bg === background_settings.name || url === chat_metadata[BG_METADATA_KEY]) {
const siblingSelector = '.bg_example';
const nextBg = bgToDelete.next(siblingSelector);
const prevBg = bgToDelete.prev(siblingSelector);
if (nextBg.length > 0) {
nextBg.trigger('click');
} else if (prevBg.length > 0) {
prevBg.trigger('click');
} else {
const anyOtherBg = $('.bg_example').not(bgToDelete).first();
if (anyOtherBg.length > 0) {
anyOtherBg.trigger('click');
}
}
}
bgToDelete.remove();
if (url === chat_metadata[BG_METADATA_KEY]) {
removeBackgroundMetadata();
}
if (isCustom) {
if (deleteFromServer) {
await deleteMediaFromServer(bg);
}
renderChatBackgrounds();
await saveMetadata();
}
highlightLockedBackground();
highlightSelectedBackground();
}
}
const autoBgPrompt = 'Ignore previous instructions and choose a location ONLY from the provided list that is the most suitable for the current scene. Do not output any other text:\n{0}';
async function autoBackgroundCommand() {
/** @type {HTMLElement[]} */
const bgTitles = Array.from(document.querySelectorAll('#bg_menu_content .BGSampleTitle'));
const options = bgTitles.map(x => ({ element: x, text: x.innerText.trim() })).filter(x => x.text.length > 0);
if (options.length == 0) {
toastr.warning('No backgrounds to choose from. Please upload some images to the "backgrounds" folder.');
return '';
}
const list = options.map(option => `- ${option.text}`).join('\n');
const prompt = stringFormat(autoBgPrompt, list);
const reply = await generateQuietPrompt({ quietPrompt: prompt });
const fuse = new Fuse(options, { keys: ['text'] });
const bestMatch = fuse.search(reply, { limit: 1 });
if (bestMatch.length == 0) {
for (const option of options) {
if (String(reply).toLowerCase().includes(option.text.toLowerCase())) {
console.debug('Fallback choosing background:', option);
option.element.click();
return '';
}
}
toastr.warning('No match found. Please try again.');
return '';
}
console.debug('Automatically choosing background:', bestMatch);
bestMatch[0].item.element.click();
return '';
}
/**
* Renders the system backgrounds gallery.
* @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
*/
function renderSystemBackgrounds(backgrounds) {
const sourceList = backgrounds || [];
const container = $('#bg_menu_content');
container.empty();
if (sourceList.length === 0) return;
sourceList.forEach(bg => {
const imageData = { filename: bg, isCustom: false };
const thumbnail = createThumbnailElement(imageData);
container.append(thumbnail);
});
activateLazyLoader();
}
/**
* Renders the chat-specific (custom) backgrounds gallery.
* @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
*/
function renderChatBackgrounds(backgrounds) {
const sourceList = backgrounds ?? (chat_metadata[LIST_METADATA_KEY] || []);
const container = $('#bg_custom_content');
container.empty();
$('#bg_chat_hint').toggle(!sourceList.length);
if (sourceList.length === 0) return;
sourceList.forEach(bg => {
const imageData = { filename: bg, isCustom: true };
const thumbnail = createThumbnailElement(imageData);
container.append(thumbnail);
});
activateLazyLoader();
}
export async function getBackgrounds() {
const response = await fetch('/api/backgrounds/all', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
});
if (response.ok) {
const { images, config } = await response.json();
Object.assign(THUMBNAIL_CONFIG, config);
renderSystemBackgrounds(images);
highlightSelectedBackground();
}
}
function activateLazyLoader() {
// Disconnect previous observer to prevent memory leaks
if (lazyLoadObserver) {
lazyLoadObserver.disconnect();
lazyLoadObserver = null;
}
const lazyLoadElements = document.querySelectorAll('.lazy-load-background');
const options = {
root: null,
rootMargin: '200px',
threshold: 0.01,
};
lazyLoadObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.target instanceof HTMLElement && entry.isIntersecting) {
const clipper = entry.target;
const parentThumbnail = clipper.closest('.bg_example');
if (parentThumbnail) {
const bg = parentThumbnail.getAttribute('bgfile');
const isCustom = parentThumbnail.getAttribute('custom') === 'true';
resolveImageUrl(bg, isCustom)
.then(url => { clipper.style.backgroundImage = url; })
.catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; });
}
clipper.classList.remove('lazy-load-background');
observer.unobserve(clipper);
}
});
}, options);
lazyLoadElements.forEach(element => {
lazyLoadObserver.observe(element);
});
}
/**
* Gets the CSS URL of the background
* @param {Element} block
* @returns {string} URL of the background
*/
function getUrlParameter(block) {
return $(block).closest('.bg_example').data('url');
}
function generateUrlParameter(bg, isCustom) {
return isCustom ? `url("${encodeURI(bg)}")` : `url("${getBackgroundPath(bg)}")`;
}
/**
* Resolves the image URL for the background.
* @param {string} bg Background file name
* @param {boolean} isCustom Is a custom background
* @returns {Promise<string>} CSS URL of the background
*/
async function resolveImageUrl(bg, isCustom) {
const fileExtension = bg.split('.').pop().toLowerCase();
const isAnimated = ['mp4', 'webp'].includes(fileExtension);
const thumbnailUrl = isAnimated && !background_settings.animation
? await getThumbnailFromStorage(bg, isCustom)
: isCustom
? bg
: getThumbnailUrl('bg', bg);
return `url("${thumbnailUrl}")`;
}
async function setBackground(bg, url) {
// Only change the visual background if one is not locked for the current chat.
if (!isChatBackgroundLocked()) {
$('#bg1').css('background-image', url);
}
background_settings.name = bg;
background_settings.url = url;
saveSettingsDebounced();
}
async function delBackground(bg) {
await fetch('/api/backgrounds/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
bg: bg,
}),
});
await THUMBNAIL_STORAGE.removeItem(bg);
if (THUMBNAIL_BLOBS.has(bg)) {
URL.revokeObjectURL(THUMBNAIL_BLOBS.get(bg));
THUMBNAIL_BLOBS.delete(bg);
}
}
/**
* Background upload handler.
* @param {Event} e Event
* @returns {Promise<void>}
*/
async function onBackgroundUploadSelected(e) {
const input = e.currentTarget;
if (!(input instanceof HTMLInputElement)) {
console.error('Invalid input element for background upload');
return;
}
for (const file of input.files) {
if (file.size === 0) {
continue;
}
const formData = new FormData();
formData.append('avatar', file);
await convertFileIfVideo(formData);
switch (getActiveBackgroundTab()) {
case BG_SOURCES.GLOBAL:
await uploadBackground(formData);
break;
case BG_SOURCES.CHAT:
await uploadChatBackground(formData);
break;
default:
console.error('Unknown background source type');
continue;
}
}
// Allow re-uploading the same file again by clearing the input value
input.value = '';
}
/**
* Converts a video file to an animated webp format if the file is a video.
* @param {FormData} formData
* @returns {Promise<void>}
*/
async function convertFileIfVideo(formData) {
const file = formData.get('avatar');
if (!(file instanceof File)) {
return;
}
if (!file.type.startsWith('video/')) {
return;
}
if (typeof globalThis.convertVideoToAnimatedWebp !== 'function') {
toastr.warning(t`Click here to install the Video Background Loader extension`, t`Video background uploads require a downloadable add-on`, {
timeOut: 0,
extendedTimeOut: 0,
onclick: () => openThirdPartyExtensionMenu('https://github.com/SillyTavern/Extension-VideoBackgroundLoader'),
});
return;
}
let toastMessage = jQuery();
try {
toastMessage = toastr.info(t`Preparing video for upload. This may take several minutes.`, t`Please wait`, { timeOut: 0, extendedTimeOut: 0 });
const sourceBuffer = await file.arrayBuffer();
const convertedBuffer = await globalThis.convertVideoToAnimatedWebp({ buffer: new Uint8Array(sourceBuffer), name: file.name });
const convertedFileName = file.name.replace(/\.[^/.]+$/, '.webp');
const convertedFile = new File([new Uint8Array(convertedBuffer)], convertedFileName, { type: 'image/webp' });
formData.set('avatar', convertedFile);
toastMessage.remove();
} catch (error) {
formData.delete('avatar');
toastMessage.remove();
console.error('Error converting video to animated webp:', error);
toastr.error(t`Error converting video to animated webp`);
}
}
/**
* Uploads a background to the server
* @param {FormData} formData
*/
async function uploadBackground(formData) {
try {
if (!formData.has('avatar')) {
console.log('No file provided. Background upload cancelled.');
return;
}
const response = await fetch('/api/backgrounds/upload', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
body: formData,
cache: 'no-cache',
});
if (!response.ok) {
throw new Error('Failed to upload background');
}
const bg = await response.text();
setBackground(bg, generateUrlParameter(bg, false));
await getBackgrounds();
highlightNewBackground(bg);
} catch (error) {
console.error('Error uploading background:', error);
}
}
/**
* Upload a chat background using a FormData object.
* @param {FormData} formData FormData containing the background file
* @returns {Promise<void>}
*/
async function uploadChatBackground(formData) {
try {
if (!getCurrentChatId()) {
toastr.warning(t`Select a chat to upload a background for it`);
return;
}
if (!formData.has('avatar')) {
console.log('No file provided. Chat background upload cancelled.');
return;
}
const file = formData.get('avatar');
if (!(file instanceof File)) {
console.error('Invalid file type for chat background upload');
return;
}
const imageDataUri = await getBase64Async(file);
const base64Data = imageDataUri.split(',')[1];
const extension = getFileExtension(file);
const characterName = selected_group
? groups.find(g => g.id === selected_group)?.id?.toString()
: characters[this_chid]?.name;
const filename = `${characterName}_${humanizedDateTime()}`;
const imagePath = await saveBase64AsFile(base64Data, characterName, filename, extension);
const list = chat_metadata[LIST_METADATA_KEY] || [];
list.push(imagePath);
chat_metadata[LIST_METADATA_KEY] = list;
await saveMetadata();
renderChatBackgrounds();
highlightNewBackground(imagePath);
highlightLockedBackground();
highlightSelectedBackground();
} catch (error) {
console.error('Error uploading chat background:', error);
}
}
/**
* @param {string} bg
*/
function highlightNewBackground(bg) {
const newBg = $(`.bg_example[bgfile="${bg}"]`);
const scrollOffset = newBg.offset().top - newBg.parent().offset().top;
$('#Backgrounds').scrollTop(scrollOffset);
flashHighlight(newBg);
}
/**
* Sets the fitting class for the background element
* @param {string} fitting Fitting type
*/
function setFittingClass(fitting) {
const backgrounds = $('#bg1');
for (const option of ['cover', 'contain', 'stretch', 'center']) {
backgrounds.toggleClass(option, option === fitting);
}
background_settings.fitting = fitting;
}
function highlightSelectedBackground() {
$('.bg_example.selected-background').removeClass('selected-background');
// The "selected" highlight should always reflect the global background setting.
const activeUrl = background_settings.url;
if (activeUrl) {
// Find the thumbnail whose data-url attribute matches the active URL
$('.bg_example').filter(function () {
return $(this).data('url') === activeUrl;
}).addClass('selected-background');
}
}
function onBackgroundFilterInput() {
const filterValue = String($('#bg-filter').val()).toLowerCase();
$('#bg_menu_content > .bg_example, #bg_custom_content > .bg_example').each(function () {
const $bg = $(this);
const title = $bg.attr('title') || '';
const hasMatch = title.toLowerCase().includes(filterValue);
$bg.toggle(hasMatch);
});
}
const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
/**
* Gets the active background tab source.
* @returns {BG_SOURCES} Active background tab source
*/
export function getActiveBackgroundTab() {
return $('#bg_tabs').tabs('option', 'active');
}
export function initBackgrounds() {
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
$(document)
.off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
.off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {
e.stopPropagation();
const $context = $(this).closest('.bg_example');
const wasOpen = $context.hasClass('mobile-menu-open');
// Close all other open menus before opening a new one.
$('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
if (!wasOpen) {
$context.addClass('mobile-menu-open');
}
})
.off('blur', '.bg_example.mobile-menu-open').on('blur', '.bg_example.mobile-menu-open', function () {
if (!$(this).is(':focus-within')) {
$(this).removeClass('mobile-menu-open');
}
})
.off('click', '.jg-button').on('click', '.jg-button', function (e) {
e.stopPropagation();
const action = $(this).data('action');
switch (action) {
case 'lock':
onLockBackgroundClick.call(this, e.originalEvent);
break;
case 'unlock':
onUnlockBackgroundClick.call(this, e.originalEvent);
break;
case 'edit':
onRenameBackgroundClick.call(this, e.originalEvent);
break;
case 'delete':
onDeleteBackgroundClick.call(this, e.originalEvent);
break;
case 'copy':
onCopyToSystemBackgroundClick.call(this, e.originalEvent);
break;
}
});
$('#bg_thumb_zoom_in').on('click', () => {
applyThumbnailColumns(background_settings.thumbnailColumns - 1);
});
$('#bg_thumb_zoom_out').on('click', () => {
applyThumbnailColumns(background_settings.thumbnailColumns + 1);
});
$('#auto_background').on('click', autoBackgroundCommand);
$('#add_bg_button').on('change', (e) => onBackgroundUploadSelected(e.originalEvent));
$('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput());
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'lockbg',
callback: () => {
onLockBackgroundClick();
return '';
},
aliases: ['bglock'],
helpString: 'Locks a background for the currently selected chat',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'unlockbg',
callback: () => {
onUnlockBackgroundClick();
return '';
},
aliases: ['bgunlock'],
helpString: 'Unlocks a background for the currently selected chat',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'autobg',
callback: autoBackgroundCommand,
aliases: ['bgauto'],
helpString: 'Automatically changes the background based on the chat context using the AI request prompt',
}));
$('#background_fitting').on('input', function () {
background_settings.fitting = String($(this).val());
setFittingClass(background_settings.fitting);
saveSettingsDebounced();
});
$('#background_thumbnails_animation').on('input', async function () {
background_settings.animation = !!$(this).prop('checked');
saveSettingsDebounced();
// Refresh background thumbnails
await getBackgrounds();
await onChatChanged();
});
Object.values(BG_TABS).forEach(tabId => {
setupScrollToTop({
scrollContainerId: tabId,
buttonId: 'bg-scroll-top',
drawerId: 'Backgrounds',
});
});
$('#bg_tabs').tabs();
}

View File

@@ -0,0 +1,674 @@
import {
characters,
saveChat,
system_message_types,
this_chid,
openCharacterChat,
chat_metadata,
getRequestHeaders,
getThumbnailUrl,
getCharacters,
chat,
saveChatConditional,
saveItemizedPrompts,
setActiveGroup,
} from '../script.js';
import { humanizedDateTime } from './RossAscends-mods.js';
import {
DEFAULT_AUTO_MODE_DELAY,
group_activation_strategy,
group_generation_mode,
groups,
openGroupById,
openGroupChat,
saveGroupBookmarkChat,
selected_group,
} from './group-chats.js';
import { hideLoader, showLoader } from './loader.js';
import { getLastMessageId } from './macros.js';
import { Popup } from './popup.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { createTagMapFromList } from './tags.js';
import { renderTemplateAsync } from './templates.js';
import { t } from './i18n.js';
import {
getUniqueName,
isTrueBoolean,
} from './utils.js';
const bookmarkNameToken = 'Checkpoint #';
/**
* Gets the names of existing chats for the current character or group.
* @returns {Promise<string[]>} - Returns a promise that resolves to an array of existing chat names.
*/
async function getExistingChatNames() {
if (selected_group) {
const group = groups.find(x => x.id == selected_group);
if (group && Array.isArray(group.chats)) {
return [...group.chats];
}
return [];
}
if (this_chid === undefined) {
return [];
}
const character = characters[this_chid];
if (!character) {
return [];
}
const response = await fetch('/api/characters/chats', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ avatar_url: character.avatar, simple: true }),
});
if (response.ok) {
const data = await response.json();
const chats = Object.values(data).map(x => x.file_name.replace('.jsonl', ''));
return [...chats];
}
return [];
}
async function getBookmarkName({ isReplace = false, forceName = null } = {}) {
const chatNames = await getExistingChatNames();
const body = await renderTemplateAsync('createCheckpoint', { isReplace: isReplace });
let name = forceName ?? await Popup.show.input('Create Checkpoint', body);
// Special handling for confirmed empty input (=> auto-generate name)
if (name === '') {
for (let i = chatNames.length; i < 1000; i++) {
name = bookmarkNameToken + i;
if (!chatNames.includes(name)) {
break;
}
}
}
if (!name) {
return null;
}
return `${name} - ${humanizedDateTime()}`;
}
function getMainChatName() {
if (chat_metadata) {
if (chat_metadata['main_chat']) {
return chat_metadata['main_chat'];
}
// groups didn't support bookmarks before chat metadata was introduced
else if (selected_group) {
return null;
}
else if (characters[this_chid].chat && characters[this_chid].chat.includes(bookmarkNameToken)) {
const tokenIndex = characters[this_chid].chat.lastIndexOf(bookmarkNameToken);
chat_metadata['main_chat'] = characters[this_chid].chat.substring(0, tokenIndex).trim();
return chat_metadata['main_chat'];
}
}
return null;
}
export function showBookmarksButtons() {
try {
if (selected_group) {
$('#option_convert_to_group').hide();
} else {
$('#option_convert_to_group').show();
}
if (chat_metadata['main_chat']) {
// In bookmark chat
$('#option_back_to_main').show();
$('#option_new_bookmark').show();
} else if (!selected_group && !characters[this_chid].chat) {
// No chat recorded on character
$('#option_back_to_main').hide();
$('#option_new_bookmark').hide();
} else {
// In main chat
$('#option_back_to_main').hide();
$('#option_new_bookmark').show();
}
}
catch {
$('#option_back_to_main').hide();
$('#option_new_bookmark').hide();
$('#option_convert_to_group').hide();
}
}
async function saveBookmarkMenu() {
if (!chat.length) {
toastr.warning('The chat is empty.', 'Checkpoint creation failed');
return;
}
return await createNewBookmark(chat.length - 1);
}
// Export is used by Timelines extension. Do not remove.
export async function createBranch(mesId) {
if (!chat.length) {
toastr.warning('The chat is empty.', 'Branch creation failed');
return;
}
if (mesId < 0 || mesId >= chat.length) {
toastr.warning('Invalid message ID.', 'Branch creation failed');
return;
}
const lastMes = chat[mesId];
const mainChat = selected_group ? groups?.find(x => x.id == selected_group)?.chat_id : characters[this_chid].chat;
const newMetadata = { main_chat: mainChat };
let name = `Branch #${mesId} - ${humanizedDateTime()}`;
if (selected_group) {
await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
} else {
await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
}
// append to branches list if it exists
// otherwise create it
if (typeof lastMes.extra !== 'object') {
lastMes.extra = {};
}
if (typeof lastMes.extra['branches'] !== 'object') {
lastMes.extra['branches'] = [];
}
lastMes.extra['branches'].push(name);
return name;
}
/**
* Creates a new bookmark for a message.
*
* @param {number} mesId - The ID of the message.
* @param {Object} [options={}] - Optional parameters.
* @param {string?} [options.forceName=null] - The name to force for the bookmark.
* @returns {Promise<string?>} - A promise that resolves to the bookmark name when the bookmark is created.
*/
export async function createNewBookmark(mesId, { forceName = null } = {}) {
if (this_chid === undefined && !selected_group) {
toastr.info('No character selected.', 'Create Checkpoint');
return null;
}
if (!chat.length) {
toastr.warning('The chat is empty.', 'Create Checkpoint');
return null;
}
if (!chat[mesId]) {
toastr.warning('Invalid message ID.', 'Create Checkpoint');
return null;
}
const lastMes = chat[mesId];
if (typeof lastMes.extra !== 'object') {
lastMes.extra = {};
}
const isReplace = lastMes.extra.bookmark_link;
let name = await getBookmarkName({ isReplace: isReplace, forceName: forceName });
if (!name) {
return null;
}
const mainChat = selected_group ? groups?.find(x => x.id == selected_group)?.chat_id : characters[this_chid].chat;
const newMetadata = { main_chat: mainChat };
await saveItemizedPrompts(name);
if (selected_group) {
await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);
} else {
await saveChat({ chatName: name, withMetadata: newMetadata, mesId });
}
lastMes.extra['bookmark_link'] = name;
const mes = $(`.mes[mesid="${mesId}"]`);
updateBookmarkDisplay(mes, name);
await saveChatConditional();
toastr.success('Click the flag icon next to the message to open the checkpoint chat.', 'Create Checkpoint', { timeOut: 10000 });
return name;
}
/**
* Updates the display of the bookmark on a chat message.
* @param {JQuery<HTMLElement>} mes - The message element
* @param {string?} [newBookmarkLink=null] - The new bookmark link (optional)
*/
export function updateBookmarkDisplay(mes, newBookmarkLink = null) {
newBookmarkLink && mes.attr('bookmark_link', newBookmarkLink);
const bookmarkFlag = mes.find('.mes_bookmark');
bookmarkFlag.attr('title', `Checkpoint\n${mes.attr('bookmark_link')}\n\n${bookmarkFlag.data('tooltip')}`);
}
async function backToMainChat() {
const mainChatName = getMainChatName();
const allChats = await getExistingChatNames();
if (allChats.includes(mainChatName)) {
if (selected_group) {
await openGroupChat(selected_group, mainChatName);
} else {
await openCharacterChat(mainChatName);
}
return mainChatName;
}
return null;
}
export async function convertSoloToGroupChat() {
if (selected_group) {
console.log('Already in group. No need for conversion');
return;
}
if (this_chid === undefined) {
console.log('Need to have a character selected');
return;
}
const confirm = await Popup.show.confirm(t`Convert to group chat`, t`Are you sure you want to convert this chat to a group chat?` + '<br />' + t`This cannot be reverted.`);
if (!confirm) {
return;
}
const character = characters[this_chid];
// Populate group required fields
const name = getUniqueName(`Group: ${character.name}`, y => groups.findIndex(x => x.name === y) !== -1);
const avatar = getThumbnailUrl('avatar', character.avatar);
const chatName = humanizedDateTime();
const chats = [chatName];
const members = [character.avatar];
const favChecked = character.fav || character.fav == 'true';
/** @type {ChatMetadata} */
const metadata = Object.assign({}, chat_metadata);
delete metadata.main_chat;
/** @type {ChatHeader} */
const chatHeader = {
chat_metadata: metadata,
user_name: 'unused',
character_name: 'unused',
};
/** @type {Omit<Group, 'id'>} */
const groupCreateModel = {
name: name,
members: members,
avatar_url: avatar,
allow_self_responses: false,
activation_strategy: group_activation_strategy.NATURAL,
disabled_members: [],
fav: favChecked,
chat_id: chatName,
chats: chats,
hideMutedSprites: false,
generation_mode: group_generation_mode.SWAP,
auto_mode_delay: DEFAULT_AUTO_MODE_DELAY,
};
const createGroupResponse = await fetch('/api/groups/create', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(groupCreateModel),
});
if (!createGroupResponse.ok) {
console.error('Group creation unsuccessful');
return;
}
/** @type {Group} */
const group = await createGroupResponse.json();
// Convert tags list and assign to group
createTagMapFromList('#tagList', group.id);
// Update chars list
await getCharacters();
// Convert chat to group format
const groupChat = [...chat].map(m => structuredClone(m));
const genIdFirst = Date.now();
for (let index = 0; index < groupChat.length; index++) {
const message = groupChat[index];
// Skip messages we don't care about
if (message.is_user || message.is_system || message.extra?.type === system_message_types.NARRATOR || message.force_avatar !== undefined) {
continue;
}
if (!message.extra || typeof message.extra !== 'object') {
message.extra = {};
}
// Set force fields for solo character
message.name = character.name;
message.original_avatar = character.avatar;
message.force_avatar = getThumbnailUrl('avatar', character.avatar);
// Allow regens of a single message in group
message.extra.gen_id = genIdFirst + index;
}
// Save group chat
const createChatResponse = await fetch('/api/chats/group/save', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ id: chatName, chat: [chatHeader, ...groupChat] }),
});
if (!createChatResponse.ok) {
console.error('Group chat creation unsuccessful');
toastr.error('Group chat creation unsuccessful');
return;
}
// Click on the freshly selected group to open it
setActiveGroup(group.id);
await openGroupById(group.id);
toastr.success(t`The chat has been successfully converted!`);
}
/**
* Creates a new branch from the message with the given ID
* @param {number} mesId Message ID
* @returns {Promise<string?>} Branch file name
*/
export async function branchChat(mesId) {
if (this_chid === undefined && !selected_group) {
toastr.info('No character selected.', 'Create Branch');
return null;
}
const fileName = await createBranch(mesId);
await saveItemizedPrompts(fileName);
if (selected_group) {
await openGroupChat(selected_group, fileName);
} else {
await openCharacterChat(fileName);
}
return fileName;
}
function registerBookmarksSlashCommands() {
/**
* Validates a message ID. (Is a number, exists as a message)
*
* @param {number} mesId - The message ID to validate.
* @param {string} context - The context of the slash command. Will be used as the title of any toasts.
* @returns {boolean} - Returns true if the message ID is valid, otherwise false.
*/
function validateMessageId(mesId, context) {
if (isNaN(mesId)) {
toastr.warning('Invalid message ID was provided', context);
return false;
}
if (!chat[mesId]) {
toastr.warning(`Message for id ${mesId} not found`, context);
return false;
}
return true;
}
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'branch-create',
returns: 'Name of the new branch',
callback: async (args, text) => {
const mesId = Number(args.mesId ?? text ?? getLastMessageId());
if (!validateMessageId(mesId, 'Create Branch')) return '';
const branchName = await branchChat(mesId);
return branchName ?? '';
},
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Message ID',
typeList: [ARGUMENT_TYPE.NUMBER],
enumProvider: commonEnumProviders.messages(),
}),
],
helpString: `
<div>
Create a new branch from the selected message. If no message id is provided, will use the last message.
</div>
<div>
Creating a branch will automatically choose a name for the branch.<br />
After creating the branch, the branch chat will be automatically opened.
</div>
<div>
Use Checkpoints and <code>/checkpoint-create</code> instead if you do not want to jump to the new chat.
</div>`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'checkpoint-create',
returns: 'Name of the new checkpoint',
callback: async (args, text) => {
const mesId = Number(args.mesId ?? getLastMessageId());
if (!validateMessageId(mesId, 'Create Checkpoint')) return '';
if (typeof text !== 'string') {
toastr.warning('Checkpoint name must be a string or empty', 'Create Checkpoint');
return '';
}
const checkPointName = await createNewBookmark(mesId, { forceName: text });
return checkPointName ?? '';
},
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'mesId',
description: 'Message ID',
typeList: [ARGUMENT_TYPE.NUMBER],
enumProvider: commonEnumProviders.messages(),
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Checkpoint name',
typeList: [ARGUMENT_TYPE.STRING],
}),
],
helpString: `
<div>
Create a new checkpoint for the selected message with the provided name. If no message id is provided, will use the last message.<br />
Leave the checkpoint name empty to auto-generate one.
</div>
<div>
A created checkpoint will be permanently linked with the message.<br />
If a checkpoint already exists, the link to it will be overwritten.<br />
After creating the checkpoint, the checkpoint chat can be opened with the checkpoint flag,
using the <code>/go</code> command with the checkpoint name or the <code>/checkpoint-go</code> command on the message.
</div>
<div>
Use Branches and <code>/branch-create</code> instead if you do want to jump to the new chat.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/checkpoint-create mes={{lastCharMessage}} Checkpoint for char reply | /setvar key=rememberCheckpoint {{pipe}}</code></pre>
Will create a new checkpoint to the latest message of the current character, and save it as a local variable for future use.
</li>
</ul>
</div>`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'checkpoint-go',
returns: 'Name of the checkpoint',
callback: async (args, text) => {
const mesId = Number(args.mesId ?? text ?? getLastMessageId());
if (!validateMessageId(mesId, 'Open Checkpoint')) return '';
const checkPointName = chat[mesId].extra?.bookmark_link;
if (!checkPointName) {
toastr.warning('No checkpoint is linked to the selected message', 'Open Checkpoint');
return '';
}
if (selected_group) {
await openGroupChat(selected_group, checkPointName);
} else {
await openCharacterChat(checkPointName);
}
return checkPointName;
},
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Message ID',
typeList: [ARGUMENT_TYPE.NUMBER],
enumProvider: commonEnumProviders.messages(),
}),
],
helpString: `
<div>
Open the checkpoint linked to the selected message. If no message id is provided, will use the last message.
</div>
<div>
Use <code>/checkpoint-get</code> if you want to make sure that the selected message has a checkpoint.
</div>`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'checkpoint-exit',
returns: 'The name of the chat exited to. Returns an empty string if not in a checkpoint chat.',
callback: async () => {
const mainChat = await backToMainChat();
return mainChat ?? '';
},
helpString: 'Exit the checkpoint chat.<br />If not in a checkpoint chat, returns empty string.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'checkpoint-parent',
returns: 'Name of the parent chat for this checkpoint',
callback: async () => {
const mainChatName = getMainChatName();
return mainChatName ?? '';
},
helpString: 'Get the name of the parent chat for this checkpoint.<br />If not in a checkpoint chat, returns empty string.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'checkpoint-get',
returns: 'Name of the chat',
callback: async (args, text) => {
const mesId = Number(args.mesId ?? text ?? getLastMessageId());
if (!validateMessageId(mesId, 'Get Checkpoint')) return '';
const checkPointName = chat[mesId].extra?.bookmark_link;
return checkPointName ?? '';
},
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Message ID',
typeList: [ARGUMENT_TYPE.NUMBER],
enumProvider: commonEnumProviders.messages(),
}),
],
helpString: `
<div>
Get the name of the checkpoint linked to the selected message. If no message id is provided, will use the last message.<br />
If no checkpoint is linked, the result will be empty.
</div>`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'checkpoint-list',
returns: 'JSON array of all existing checkpoints in this chat, as an array',
/** @param {{links?: string}} args @returns {Promise<string>} */
callback: async (args, _) => {
const result = Object.entries(chat)
.filter(([_, message]) => message.extra?.bookmark_link)
.map(([mesId, message]) => isTrueBoolean(args.links) ? message.extra.bookmark_link : Number(mesId));
return JSON.stringify(result);
},
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'links',
description: 'Get a list of all links / chat names of the checkpoints, instead of the message ids',
typeList: [ARGUMENT_TYPE.BOOLEAN],
enumList: commonEnumProviders.boolean('trueFalse')(),
defaultValue: 'false',
}),
],
helpString: `
<div>
List all existing checkpoints in this chat.
</div>
<div>
Returns a list of all message ids that have a checkpoint, or all checkpoint links if <code>links</code> is set to <code>true</code>.<br />
The value will be a JSON array.
</div>`,
}));
}
export function initBookmarks() {
$('#option_new_bookmark').on('click', saveBookmarkMenu);
$('#option_back_to_main').on('click', backToMainChat);
$('#option_convert_to_group').on('click', convertSoloToGroupChat);
$(document).on('click', '.select_chat_block, .mes_bookmark', async function (e) {
// If shift is held down, we are not following the bookmark, but creating a new one
const mes = $(this).closest('.mes');
if (e.shiftKey && mes.length) {
const selectedMesId = mes.attr('mesid');
await createNewBookmark(Number(selectedMesId));
return;
}
const fileName = $(this).hasClass('mes_bookmark')
? $(this).closest('.mes').attr('bookmark_link')
: $(this).attr('file_name').replace('.jsonl', '');
if (!fileName) {
return;
}
try {
showLoader();
if (selected_group) {
await openGroupChat(selected_group, fileName);
} else {
await openCharacterChat(fileName);
}
} finally {
await hideLoader();
}
$('#shadow_select_chat_popup').css('display', 'none');
});
$(document).on('click', '.mes_create_bookmark', async function () {
const mesId = $(this).closest('.mes').attr('mesid');
if (mesId !== undefined) {
await createNewBookmark(Number(mesId));
}
});
$(document).on('click', '.mes_create_branch', async function () {
const mesId = $(this).closest('.mes').attr('mesid');
if (mesId !== undefined) {
await branchChat(Number(mesId));
}
});
registerBookmarksSlashCommands();
}

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

View File

@@ -0,0 +1,128 @@
import { characterGroupOverlay } from '../script.js';
import { BulkEditOverlay, BulkEditOverlayState, CharacterContextMenu } from './BulkEditOverlay.js';
import { event_types, eventSource } from './events.js';
let is_bulk_edit = false;
const enableBulkEdit = () => {
enableBulkSelect();
characterGroupOverlay.selectState();
// show the bulk edit option buttons
$('.bulkEditOptionElement').show();
is_bulk_edit = true;
characterGroupOverlay.updateSelectedCount(0);
};
const disableBulkEdit = () => {
disableBulkSelect();
characterGroupOverlay.browseState();
// hide the bulk edit option buttons
$('.bulkEditOptionElement').hide();
is_bulk_edit = false;
characterGroupOverlay.updateSelectedCount(0);
};
const toggleBulkEditMode = (isBulkEdit) => {
if (isBulkEdit) {
disableBulkEdit();
} else {
enableBulkEdit();
}
};
/**
* Toggles bulk edit mode on/off when the edit button is clicked.
*/
function onEditButtonClick() {
console.log('Edit button clicked');
toggleBulkEditMode(is_bulk_edit);
}
/**
* Toggles the select state of all characters in bulk edit mode to selected. If all are selected, they'll be deselected.
*/
function onSelectAllButtonClick() {
console.log('Bulk select all button clicked');
const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass));
let atLeastOneSelected = false;
for (const character of characters) {
const checked = $(character).find('.bulk_select_checkbox:checked').length > 0;
if (!checked && character instanceof HTMLElement) {
characterGroupOverlay.toggleSingleCharacter(character);
atLeastOneSelected = true;
}
}
if (!atLeastOneSelected) {
// If none was selected, trigger click on all to deselect all of them
for(const character of characters) {
const checked = $(character).find('.bulk_select_checkbox:checked') ?? false;
if (checked && character instanceof HTMLElement) {
characterGroupOverlay.toggleSingleCharacter(character);
}
}
}
}
/**
* Deletes all characters that have been selected via the bulk checkboxes.
*/
async function onDeleteButtonClick() {
console.log('Delete button clicked');
// We just let the button trigger the context menu delete option
await characterGroupOverlay.handleContextMenuDelete();
}
/**
* Enables bulk selection by adding a checkbox next to each character.
*/
function enableBulkSelect() {
$('#rm_print_characters_block .character_select').each((i, el) => {
// Prevent checkbox from adding multiple times (because of stage change callback)
if ($(el).find('.bulk_select_checkbox').length > 0) {
return;
}
const checkbox = $('<input type=\'checkbox\' class=\'bulk_select_checkbox\'>');
checkbox.on('change', () => {
// Do something when the checkbox is changed
});
$(el).prepend(checkbox);
});
$('#rm_print_characters_block.group_overlay_mode_select .bogus_folder_select, #rm_print_characters_block.group_overlay_mode_select .group_select')
.addClass('disabled');
$('#rm_print_characters_block').addClass('bulk_select');
// We also need to disable the default click event for the character_select divs
$(document).on('click', '.bulk_select_checkbox', function (event) {
event.stopImmediatePropagation();
});
}
/**
* Disables bulk selection by removing the checkboxes.
*/
function disableBulkSelect() {
$('.bulk_select_checkbox').remove();
$('#rm_print_characters_block.group_overlay_mode_select .bogus_folder_select, #rm_print_characters_block.group_overlay_mode_select .group_select')
.removeClass('disabled');
$('#rm_print_characters_block').removeClass('bulk_select');
}
/**
* Entry point that runs on page load.
*/
export function initBulkEdit() {
characterGroupOverlay.addStateChangeCallback((state) => {
if (state === BulkEditOverlayState.select) enableBulkEdit();
if (state === BulkEditOverlayState.browse) disableBulkEdit();
});
$('#bulkEditButton').on('click', onEditButtonClick);
$('#bulkSelectAllButton').on('click', onSelectAllButtonClick);
$('#bulkDeleteButton').on('click', onDeleteButtonClick);
const characterContextMenu = new CharacterContextMenu(characterGroupOverlay);
eventSource.on(event_types.CHARACTER_PAGE_LOADED, characterGroupOverlay.onPageLoad);
console.debug('Character context menu initialized', characterContextMenu);
}

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').trigger('click');
}
} else {
//hide AN if it's already displayed
$('#cfgConfig').addClass('resizing');
$('#cfgConfig').transition({
opacity: 0.0,
duration: animation_duration,
}, async function () {
await delay(50);
$('#cfgConfig').removeClass('resizing');
});
setTimeout(function () {
$('#cfgConfig').hide();
}, animation_duration);
}
//duplicate options menu close handler from script.js
//because this listener takes priority
$('#options').stop().fadeOut(animation_duration);
}
async function onChatChanged() {
loadSettings();
await modifyCharaHtml();
}
// Rearrange the panel if a group chat is present
async function modifyCharaHtml() {
if (selected_group) {
$('#chara_cfg_container').hide();
$('#groupchat_cfg_use_chara_container').show();
} else {
$('#chara_cfg_container').show();
$('#groupchat_cfg_use_chara_container').hide();
// TODO: Remove chat checkbox here
}
}
// Reloads chat-specific settings
function loadSettings() {
// Set chat CFG if it exists
$('#chat_cfg_guidance_scale').val(chat_metadata[metadataKeys.guidance_scale] ?? 1.0.toFixed(2));
$('#chat_cfg_guidance_scale_counter').val(chat_metadata[metadataKeys.guidance_scale]?.toFixed(2) ?? 1.0.toFixed(2));
$('#chat_cfg_negative_prompt').val(chat_metadata[metadataKeys.negative_prompt] ?? '');
$('#chat_cfg_positive_prompt').val(chat_metadata[metadataKeys.positive_prompt] ?? '');
$('#groupchat_cfg_use_chara').prop('checked', chat_metadata[metadataKeys.groupchat_individual_chars] ?? false);
if (chat_metadata[metadataKeys.prompt_combine]?.length > 0) {
chat_metadata[metadataKeys.prompt_combine].forEach((element) => {
$(`input[name="cfg_prompt_combine"][value="${element}"]`)
.prop('checked', true);
});
}
// Display the negative separator in quotes if not quoted already
let promptSeparatorDisplay = [];
const promptSeparator = chat_metadata[metadataKeys.prompt_separator];
if (promptSeparator) {
promptSeparatorDisplay.push(promptSeparator);
if (!promptSeparator.startsWith('"')) {
promptSeparatorDisplay.unshift('"');
}
if (!promptSeparator.endsWith('"')) {
promptSeparatorDisplay.push('"');
}
}
$('#cfg_prompt_separator').val(promptSeparatorDisplay.length === 0 ? '' : promptSeparatorDisplay.join(''));
$('#cfg_prompt_insertion_depth').val(chat_metadata[metadataKeys.prompt_insertion_depth] ?? 1);
// Set character CFG if it exists
if (!selected_group) {
const charaCfg = extension_settings.cfg.chara.find((e) => e.name === getCharaFilename());
$('#chara_cfg_guidance_scale').val(charaCfg?.guidance_scale ?? 1.00);
$('#chara_cfg_guidance_scale_counter').val(charaCfg?.guidance_scale?.toFixed(2) ?? 1.0.toFixed(2));
$('#chara_cfg_negative_prompt').val(charaCfg?.negative_prompt ?? '');
$('#chara_cfg_positive_prompt').val(charaCfg?.positive_prompt ?? '');
}
}
// Load initial extension settings
async function initialLoadSettings() {
// Create the settings if they don't exist
extension_settings[extensionName] = extension_settings[extensionName] || {};
if (Object.keys(extension_settings[extensionName]).length === 0) {
Object.assign(extension_settings[extensionName], defaultSettings);
saveSettingsDebounced();
}
// Set global CFG values on load
$('#global_cfg_guidance_scale').val(extension_settings.cfg.global.guidance_scale);
$('#global_cfg_guidance_scale_counter').val(extension_settings.cfg.global.guidance_scale.toFixed(2));
$('#global_cfg_negative_prompt').val(extension_settings.cfg.global.negative_prompt);
$('#global_cfg_positive_prompt').val(extension_settings.cfg.global.positive_prompt);
}
function migrateSettings() {
let performSettingsSave = false;
let performMetaSave = false;
if (power_user.guidance_scale) {
extension_settings.cfg.global.guidance_scale = power_user.guidance_scale;
delete power_user['guidance_scale'];
performSettingsSave = true;
}
if (power_user.negative_prompt) {
extension_settings.cfg.global.negative_prompt = power_user.negative_prompt;
delete power_user['negative_prompt'];
performSettingsSave = true;
}
if (chat_metadata['cfg_negative_combine']) {
chat_metadata[metadataKeys.prompt_combine] = chat_metadata['cfg_negative_combine'];
chat_metadata['cfg_negative_combine'] = undefined;
performMetaSave = true;
}
if (chat_metadata['cfg_negative_insertion_depth']) {
chat_metadata[metadataKeys.prompt_insertion_depth] = chat_metadata['cfg_negative_insertion_depth'];
chat_metadata['cfg_negative_insertion_depth'] = undefined;
performMetaSave = true;
}
if (chat_metadata['cfg_negative_separator']) {
chat_metadata[metadataKeys.prompt_separator] = chat_metadata['cfg_negative_separator'];
chat_metadata['cfg_negative_separator'] = undefined;
performMetaSave = true;
}
if (performSettingsSave) {
saveSettingsDebounced();
}
if (performMetaSave) {
saveMetadataDebounced();
}
}
// This function is called when the extension is loaded
export function initCfg() {
$('#CFGClose').on('click', function () {
$('#cfgConfig').transition({
opacity: 0,
duration: animation_duration,
easing: 'ease-in-out',
});
setTimeout(function () { $('#cfgConfig').hide(); }, animation_duration);
});
$('#chat_cfg_guidance_scale').on('input', function () {
const numberValue = Number($(this).val());
const success = setChatCfg(numberValue, settingType.guidance_scale);
if (success) {
$('#chat_cfg_guidance_scale_counter').val(numberValue.toFixed(2));
}
});
$('#chat_cfg_negative_prompt').on('input', function () {
setChatCfg($(this).val(), settingType.negative_prompt);
});
$('#chat_cfg_positive_prompt').on('input', function () {
setChatCfg($(this).val(), settingType.positive_prompt);
});
$('#chara_cfg_guidance_scale').on('input', function () {
const value = $(this).val();
const success = setCharCfg(value, settingType.guidance_scale);
if (success) {
$('#chara_cfg_guidance_scale_counter').val(Number(value).toFixed(2));
}
});
$('#chara_cfg_negative_prompt').on('input', function () {
setCharCfg($(this).val(), settingType.negative_prompt);
});
$('#chara_cfg_positive_prompt').on('input', function () {
setCharCfg($(this).val(), settingType.positive_prompt);
});
$('#global_cfg_guidance_scale').on('input', function () {
extension_settings.cfg.global.guidance_scale = Number($(this).val());
$('#global_cfg_guidance_scale_counter').val(extension_settings.cfg.global.guidance_scale.toFixed(2));
saveSettingsDebounced();
});
$('#global_cfg_negative_prompt').on('input', function () {
extension_settings.cfg.global.negative_prompt = $(this).val();
saveSettingsDebounced();
});
$('#global_cfg_positive_prompt').on('input', function () {
extension_settings.cfg.global.positive_prompt = $(this).val();
saveSettingsDebounced();
});
$('input[name="cfg_prompt_combine"]').on('input', function () {
const values = $('#cfgConfig').find('input[name="cfg_prompt_combine"]')
.filter(':checked')
.map(function () { return Number($(this).val()); })
.get()
.filter((e) => !Number.isNaN(e)) || [];
chat_metadata[metadataKeys.prompt_combine] = values;
saveMetadataDebounced();
});
$('#cfg_prompt_insertion_depth').on('input', function () {
chat_metadata[metadataKeys.prompt_insertion_depth] = Number($(this).val());
saveMetadataDebounced();
});
$('#cfg_prompt_separator').on('input', function () {
chat_metadata[metadataKeys.prompt_separator] = $(this).val();
saveMetadataDebounced();
});
$('#groupchat_cfg_use_chara').on('input', function () {
const checked = !!$(this).prop('checked');
chat_metadata[metadataKeys.groupchat_individual_chars] = checked;
if (checked) {
toastr.info('You can edit character CFG values in their respective character chats.');
}
saveMetadataDebounced();
});
initialLoadSettings();
if (extension_settings.cfg) {
migrateSettings();
}
$('#option_toggle_CFG').on('click', onCfgMenuItemClick);
// Hook events
eventSource.on(event_types.CHAT_CHANGED, async () => {
await onChatChanged();
});
}
export const cfgType = {
chat: 0,
chara: 1,
global: 2,
};
export const metadataKeys = {
guidance_scale: 'cfg_guidance_scale',
negative_prompt: 'cfg_negative_prompt',
positive_prompt: 'cfg_positive_prompt',
prompt_combine: 'cfg_prompt_combine',
groupchat_individual_chars: 'cfg_groupchat_individual_chars',
prompt_insertion_depth: 'cfg_prompt_insertion_depth',
prompt_separator: 'cfg_prompt_separator',
};
// Gets the CFG guidance scale
// If the guidance scale is 1, ignore the CFG prompt(s) since it won't be used anyways
export function getGuidanceScale() {
if (!extension_settings.cfg) {
console.warn('CFG extension is not enabled. Skipping CFG guidance.');
return;
}
const charaCfg = extension_settings.cfg.chara?.find((e) => e.name === getCharaFilename(this_chid));
const chatGuidanceScale = chat_metadata[metadataKeys.guidance_scale];
const groupchatCharOverride = chat_metadata[metadataKeys.groupchat_individual_chars] ?? false;
if (chatGuidanceScale && chatGuidanceScale !== 1 && !groupchatCharOverride) {
return {
type: cfgType.chat,
value: chatGuidanceScale,
};
}
if ((!selected_group && charaCfg || groupchatCharOverride) && charaCfg?.guidance_scale !== 1) {
return {
type: cfgType.chara,
value: charaCfg.guidance_scale,
};
}
if (extension_settings.cfg.global && extension_settings.cfg.global?.guidance_scale !== 1) {
return {
type: cfgType.global,
value: extension_settings.cfg.global.guidance_scale,
};
}
}
/**
* Gets the CFG prompt separator.
* @returns {string} The CFG prompt separator
*/
function getCustomSeparator() {
const defaultSeparator = '\n';
try {
if (chat_metadata[metadataKeys.prompt_separator]) {
return JSON.parse(chat_metadata[metadataKeys.prompt_separator]);
}
return defaultSeparator;
} catch {
console.warn('Invalid JSON detected for prompt separator. Using default separator.');
return defaultSeparator;
}
}
/**
* Gets the CFG prompt based on the guidance scale.
* @param {{type: number, value: number}} guidanceScale The CFG guidance scale
* @param {boolean} isNegative Whether to get the negative prompt
* @param {boolean} quiet Whether to suppress console output
* @returns {{value: string, depth: number}} The CFG prompt and insertion depth
*/
export function getCfgPrompt(guidanceScale, isNegative, quiet = false) {
let splitCfgPrompt = [];
const cfgPromptCombine = chat_metadata[metadataKeys.prompt_combine] ?? [];
if (guidanceScale.type === cfgType.chat || cfgPromptCombine.includes(cfgType.chat)) {
splitCfgPrompt.unshift(
substituteParams(
chat_metadata[isNegative ? metadataKeys.negative_prompt : metadataKeys.positive_prompt],
),
);
}
const charaCfg = extension_settings.cfg.chara?.find((e) => e.name === getCharaFilename(this_chid));
if (guidanceScale.type === cfgType.chara || cfgPromptCombine.includes(cfgType.chara)) {
splitCfgPrompt.unshift(
substituteParams(
isNegative ? charaCfg.negative_prompt : charaCfg.positive_prompt,
),
);
}
if (guidanceScale.type === cfgType.global || cfgPromptCombine.includes(cfgType.global)) {
splitCfgPrompt.unshift(
substituteParams(
isNegative ? extension_settings.cfg.global.negative_prompt : extension_settings.cfg.global.positive_prompt,
),
);
}
const customSeparator = getCustomSeparator();
const combinedCfgPrompt = splitCfgPrompt.filter((e) => e.length > 0).join(customSeparator);
const insertionDepth = chat_metadata[metadataKeys.prompt_insertion_depth] ?? 1;
!quiet && console.log(`Setting CFG with guidance scale: ${guidanceScale.value}, negatives: ${combinedCfgPrompt}`);
return {
value: combinedCfgPrompt,
depth: insertionDepth,
};
}

View File

@@ -0,0 +1,124 @@
/**
* @typedef {object} v2DataWorldInfoEntry
* @property {string[]} keys - An array of primary keys associated with the entry.
* @property {string[]} secondary_keys - An array of secondary keys associated with the entry (optional).
* @property {string} comment - A human-readable description or explanation for the entry.
* @property {string} content - The main content or data associated with the entry.
* @property {boolean} constant - Indicates if the entry's content is fixed and unchangeable.
* @property {boolean} selective - Indicates if the entry's inclusion is controlled by specific conditions.
* @property {number} insertion_order - Defines the order in which the entry is inserted during processing.
* @property {boolean} enabled - Controls whether the entry is currently active and used.
* @property {string} position - Specifies the location or context where the entry applies.
* @property {v2DataWorldInfoEntryExtensionInfos} extensions - An object containing additional details for extensions associated with the entry.
* @property {number} id - A unique identifier assigned to the entry.
*/
/**
* @typedef {object} v2DataWorldInfoEntryExtensionInfos
* @property {number} position - The order in which the extension is applied relative to other extensions.
* @property {boolean} exclude_recursion - Prevents the extension from being applied recursively.
* @property {number} probability - The chance (between 0 and 1) of the extension being applied.
* @property {boolean} useProbability - Determines if the `probability` property is used.
* @property {number} depth - The maximum level of nesting allowed for recursive application of the extension.
* @property {number} selectiveLogic - Defines the logic used to determine if the extension is applied selectively.
* @property {string} group - A category or grouping for the extension.
* @property {boolean} group_override - Overrides any existing group assignment for the extension.
* @property {number} group_weight - A value used for prioritizing extensions within the same group.
* @property {boolean} prevent_recursion - Completely disallows recursive application of the extension.
* @property {boolean} delay_until_recursion - Will only be checked during recursion.
* @property {number} scan_depth - The maximum depth to search for matches when applying the extension.
* @property {boolean} match_whole_words - Specifies if only entire words should be matched during extension application.
* @property {boolean} use_group_scoring - Indicates if group weight is considered when selecting extensions.
* @property {boolean} case_sensitive - Controls whether case sensitivity is applied during matching for the extension.
* @property {string} automation_id - An identifier used for automation purposes related to the extension.
* @property {number} role - The specific function or purpose of the extension.
* @property {boolean} vectorized - Indicates if the extension is optimized for vectorized processing.
* @property {number} display_index - The order in which the extension should be displayed for user interfaces.
* @property {boolean} match_persona_description - Wether to match against the persona description.
* @property {boolean} match_character_description - Wether to match against the persona description.
* @property {boolean} match_character_personality - Wether to match against the character personality.
* @property {boolean} match_character_depth_prompt - Wether to match against the character depth prompt.
* @property {boolean} match_scenario - Wether to match against the character scenario.
* @property {boolean} match_creator_notes - Wether to match against the character creator notes.
*/
/**
* @typedef {object} v2WorldInfoBook
* @property {string} name - the name of the book
* @property {v2DataWorldInfoEntry[]} entries - the entries of the book
*/
/**
* @typedef {object} v2CharData
* @property {string} name - The character's name.
* @property {string} description - A brief description of the character.
* @property {string} character_version - The character's data version.
* @property {string} personality - A short summary of the character's personality traits.
* @property {string} scenario - A description of the character's background or setting.
* @property {string} first_mes - The character's opening message in a conversation.
* @property {string} mes_example - An example message demonstrating the character's conversation style.
* @property {string} creator_notes - Internal notes or comments left by the character's creator.
* @property {string[]} tags - A list of keywords or labels associated with the character.
* @property {string} system_prompt - The system prompt used to interact with the character.
* @property {string} post_history_instructions - Instructions for handling the character's conversation history.
* @property {string} creator - The name of the person who created the character.
* @property {string[]} alternate_greetings - Additional greeting messages the character can use.
* @property {v2WorldInfoBook} character_book - Data about the character's world or story (if applicable).
* @property {v2CharDataExtensionInfos} extensions - Additional details specific to the character.
*/
/**
* @typedef {object} v2CharDataExtensionInfos
* @property {number} talkativeness - A numerical value indicating the character's propensity to talk.
* @property {boolean} fav - A flag indicating whether the character is a favorite.
* @property {string} world - The fictional world or setting where the character exists (if applicable).
* @property {object} depth_prompt - Prompts used to explore the character's depth and complexity.
* @property {number} depth_prompt.depth - The level of detail or nuance targeted by the prompt.
* @property {string} depth_prompt.prompt - The actual prompt text used for deeper character interaction.
* @property {"system" | "user" | "assistant"} depth_prompt.role - The role the character takes on during the prompted interaction (system, user, or assistant).
* @property {RegexScriptData[]} regex_scripts - Custom regex scripts for the character.
* // Non-standard extensions added by external tools
* @property {string} [pygmalion_id] - The unique identifier assigned to the character by the Pygmalion.chat.
* @property {string} [github_repo] - The gitHub repository associated with the character.
* @property {string} [source_url] - The source URL associated with the character.
* @property {{full_path: string}} [chub] - The Chub-specific data associated with the character.
* @property {{source: string[]}} [risuai] - The RisuAI-specific data associated with the character.
* @property {{positive: string, negative: string}} [sd_character_prompt] - SD-specific data associated with the character.
*/
/**
* @typedef {object} RegexScriptData
* @property {string} id - UUID of the script
* @property {string} scriptName - The name of the script
* @property {string} findRegex - The regex to find
* @property {string} replaceString - The string to replace
* @property {string[]} trimStrings - The strings to trim
* @property {number[]} placement - The placement of the script
* @property {boolean} disabled - Whether the script is disabled
* @property {boolean} markdownOnly - Whether the script only applies to Markdown
* @property {boolean} promptOnly - Whether the script only applies to prompts
* @property {boolean} runOnEdit - Whether the script runs on edit
* @property {number} substituteRegex - Whether the regex should be substituted
* @property {number} minDepth - The minimum depth
* @property {number} maxDepth - The maximum depth
*/
/**
* @typedef {object} v1CharData
* @property {string} name - the name of the character
* @property {string} description - the description of the character
* @property {string} personality - a short personality description of the character
* @property {string} scenario - a scenario description of the character
* @property {string} first_mes - the first message in the conversation
* @property {string} mes_example - the example message in the conversation
* @property {string} creatorcomment - creator's notes of the character
* @property {string[]} tags - the tags of the character
* @property {number} talkativeness - talkativeness
* @property {boolean|string} fav - fav
* @property {string} create_date - create_date
* @property {v2CharData} data - v2 data extension
* // Non-standard extensions added by the ST server (not part of the original data)
* @property {string} chat - name of the current chat file chat
* @property {string} avatar - file name of the avatar image (acts as a unique identifier)
* @property {string} json_data - the full raw JSON data of the character
* @property {boolean?} shallow - if the data is shallow (lazy-loaded)
*/
export default 0;// now this file is a module

View File

@@ -0,0 +1,335 @@
import { t } from './i18n.js';
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
import { getFileExtension, sortMoments, timestampToMoment } from './utils.js';
import { displayPastChats, getRequestHeaders, importCharacterChat } from '/script.js';
import { importGroupChat } from './group-chats.js';
class BackupsBrowser {
/** @type {HTMLElement} */
#buttonElement;
/** @type {HTMLElement} */
#buttonChevronIcon;
/** @type {HTMLElement} */
#backupsListElement;
/** @type {AbortController} */
#loadingAbortController;
/** @type {boolean} */
#isOpen = false;
get isOpen() {
return this.#isOpen;
}
/**
* View a backup file content.
* @param {string} name File name of the backup to view.
* @returns {Promise<void>}
*/
async viewBackup(name) {
const response = await fetch('/api/backups/chat/download', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: name }),
});
if (!response.ok) {
toastr.error(t`Failed to download backup, try again later.`);
console.error('Failed to download chat backup:', response.statusText);
return;
}
try {
/** @type {ChatMessage[]} */
const parsedLines = [];
const fileText = await response.text();
fileText.split('\n').forEach(line => {
try {
/** @type {ChatMessage} */
const lineData = JSON.parse(line);
if (lineData?.mes) {
parsedLines.push(lineData);
}
} catch (error) {
console.error('Failed to parse chat backup line:', error);
}
});
const textArea = document.createElement('textarea');
textArea.classList.add('text_pole', 'monospace', 'textarea_compact', 'margin0', 'height100p');
textArea.readOnly = true;
textArea.value = parsedLines.map(l => `${l.name} [${timestampToMoment(l.send_date).format('lll')}]\n${l.mes}`).join('\n\n\n');
await callGenericPopup(textArea, POPUP_TYPE.TEXT, '', { allowVerticalScrolling: true, large: true, wide: true });
} catch (error) {
console.error('Failed to parse chat backup content:', error);
toastr.error(t`Failed to parse backup content.`);
return;
}
}
/**
* Restore a backup by importing it.
* @param {string} name File name of the backup to restore.
* @returns {Promise<void>}
*/
async restoreBackup(name) {
const response = await fetch('/api/backups/chat/download', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: name }),
});
if (!response.ok) {
toastr.error(t`Failed to download backup, try again later.`);
console.error('Failed to download chat backup:', response.statusText);
return;
}
const blob = await response.blob();
const file = new File([blob], name, { type: 'application/octet-stream' });
const extension = getFileExtension(file);
if (extension !== 'jsonl') {
toastr.warning(t`Only .jsonl files are supported for chat imports.`);
return;
}
const context = SillyTavern.getContext();
const formData = new FormData();
formData.set('file_type', extension);
formData.set('avatar', file);
formData.set('avatar_url', context.characters[context.characterId]?.avatar || '');
formData.set('user_name', context.name1);
formData.set('character_name', context.name2);
const importFn = context.groupId ? importGroupChat : importCharacterChat;
const result = await importFn(formData, { refresh: false });
if (result.length === 0) {
toastr.error(t`Failed to import chat backup, try again later.`);
return;
}
toastr.success(`Chat imported: ${result.join(', ')}`);
await displayPastChats(result);
}
/**
* Delete a backup file.
* @param {string} name File name of the backup to delete.
* @returns {Promise<boolean>} True if deleted, false otherwise.
*/
async deleteBackup(name) {
const confirm = await Popup.show.confirm(t`Are you sure?`);
if (!confirm) {
return false;
}
const response = await fetch('/api/backups/chat/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: name }),
});
if (!response.ok) {
toastr.error(t`Failed to delete backup, try again later.`);
console.error('Failed to delete chat backup:', response.statusText);
return false;
}
toastr.success(t`Backup deleted successfully.`);
return true;
}
/**
* Load backups and populate the list element.
* @param {AbortSignal} signal Signal to abort loading.
* @returns {Promise<void>}
*/
async loadBackupsIntoList(signal) {
if (!this.#backupsListElement) {
return;
}
this.#backupsListElement.innerHTML = '';
const response = await fetch('/api/backups/chat/get', {
method: 'POST',
headers: getRequestHeaders(),
signal,
});
if (!response.ok) {
console.error('Failed to load chat backups list:', response.statusText);
return;
}
/** @type {import('../../src/endpoints/chats.js').ChatInfo[]} */
const backupsList = await response.json();
for (const backup of backupsList.sort((a, b) => sortMoments(timestampToMoment(a.last_mes), timestampToMoment(b.last_mes)))) {
const listItem = document.createElement('div');
listItem.classList.add('chatBackupsListItem');
const backupName = document.createElement('div');
backupName.textContent = backup.file_name;
backupName.classList.add('chatBackupsListItemName');
const backupInfo = document.createElement('div');
backupInfo.classList.add('chatBackupsListItemInfo');
backupInfo.textContent = `${timestampToMoment(backup.last_mes).format('lll')} (${backup.file_size}, ${backup.chat_items} 💬)`;
const actionsList = document.createElement('div');
actionsList.classList.add('chatBackupsListItemActions');
const viewButton = document.createElement('div');
viewButton.classList.add('right_menu_button', 'fa-solid', 'fa-eye');
viewButton.title = t`View backup`;
viewButton.addEventListener('click', async () => {
await this.viewBackup(backup.file_name);
});
const restoreButton = document.createElement('div');
restoreButton.classList.add('right_menu_button', 'fa-solid', 'fa-rotate-left');
restoreButton.title = t`Restore backup`;
restoreButton.addEventListener('click', async () => {
await this.restoreBackup(backup.file_name);
});
const deleteButton = document.createElement('div');
deleteButton.classList.add('right_menu_button', 'fa-solid', 'fa-trash');
deleteButton.title = t`Delete backup`;
deleteButton.addEventListener('click',async () => {
const isDeleted = await this.deleteBackup(backup.file_name);
if (isDeleted) {
listItem.remove();
}
});
actionsList.appendChild(viewButton);
actionsList.appendChild(restoreButton);
actionsList.appendChild(deleteButton);
listItem.appendChild(backupName);
listItem.appendChild(backupInfo);
listItem.appendChild(actionsList);
this.#backupsListElement.appendChild(listItem);
}
}
closeBackups() {
if (!this.#isOpen) {
return;
}
this.#isOpen = false;
if (this.#buttonChevronIcon) {
this.#buttonChevronIcon.classList.remove('fa-chevron-up');
this.#buttonChevronIcon.classList.add('fa-chevron-down');
}
if (this.#backupsListElement) {
this.#backupsListElement.classList.remove('open');
this.#backupsListElement.innerHTML = '';
}
if (this.#loadingAbortController) {
this.#loadingAbortController.abort();
this.#loadingAbortController = null;
}
}
openBackups() {
if (this.#isOpen) {
return;
}
this.#isOpen = true;
if (this.#buttonChevronIcon) {
this.#buttonChevronIcon.classList.remove('fa-chevron-down');
this.#buttonChevronIcon.classList.add('fa-chevron-up');
}
if (this.#backupsListElement) {
this.#backupsListElement.classList.add('open');
}
if (this.#loadingAbortController) {
this.#loadingAbortController.abort();
this.#loadingAbortController = null;
}
this.#loadingAbortController = new AbortController();
this.loadBackupsIntoList(this.#loadingAbortController.signal);
}
renderButton() {
if (this.#buttonElement) {
return;
}
const sibling = document.getElementById('select_chat_search');
if (!sibling) {
console.error('Could not find sibling element for BackupsBrowser button');
return;
}
const button = document.createElement('button');
button.classList.add('menu_button', 'menu_button_icon');
const buttonIcon = document.createElement('i');
buttonIcon.classList.add('fa-solid', 'fa-box-open');
const buttonText = document.createElement('span');
buttonText.textContent = t`Backups`;
buttonText.title = t`Browse chat backups`;
const chevronIcon = document.createElement('i');
chevronIcon.classList.add('fa-solid', 'fa-chevron-down', 'fa-sm');
button.appendChild(buttonIcon);
button.appendChild(buttonText);
button.appendChild(chevronIcon);
button.addEventListener('click', () => {
if (this.#isOpen) {
this.closeBackups();
} else {
this.openBackups();
}
});
sibling.parentNode.insertBefore(button, sibling);
this.#buttonElement = button;
this.#buttonChevronIcon = chevronIcon;
}
renderBackupsList() {
if (this.#backupsListElement) {
return;
}
const sibling = document.getElementById('select_chat_div');
if (!sibling) {
console.error('Could not find sibling element for BackupsBrowser list');
return;
}
const list = document.createElement('div');
list.classList.add('chatBackupsList');
sibling.parentNode.insertBefore(list, sibling);
this.#backupsListElement = list;
}
}
const backupsBrowser = new BackupsBrowser();
export function addChatBackupsBrowser() {
backupsBrowser.renderButton();
backupsBrowser.renderBackupsList();
// Refresh the backups list if it's already open
if (backupsBrowser.isOpen) {
backupsBrowser.closeBackups();
backupsBrowser.openBackups();
}
}

View File

@@ -0,0 +1,198 @@
import { t } from './i18n.js';
// the hash can be obtained from command line e.g. via: MODEL=path_to_model; python -c "import json, hashlib, sys; print(hashlib.sha256(json.load(open('"$MODEL"/tokenizer_config.json'))['chat_template'].encode()).hexdigest())"
// note that chat templates must be trimmed to match the llama.cpp metadata value
const hash_derivations = {
// Meta
'e10ca381b1ccc5cf9db52e371f3b6651576caee0a630b452e2816b2d404d4b65':
// Meta-Llama-3.1-8B-Instruct
// Meta-Llama-3.1-70B-Instruct
'Llama 3 Instruct'
,
'5816fce10444e03c2e9ee1ef8a4a1ea61ae7e69e438613f3b17b69d0426223a4':
// Llama-3.2-1B-Instruct
// Llama-3.2-3B-Instruct
'Llama 3 Instruct'
,
'73e87b1667d87ab7d7b579107f01151b29ce7f3ccdd1018fdc397e78be76219d':
// Nemotron 70B
'Llama 3 Instruct'
,
// Mistral
// Mistral Reference: https://github.com/mistralai/mistral-common
'e16746b40344d6c5b5265988e0328a0bf7277be86f1c335156eae07e29c82826':
// Mistral-Small-Instruct-2409
// Mistral-Large-Instruct-2407
'Mistral V2 & V3'
,
'26a59556925c987317ce5291811ba3b7f32ec4c647c400c6cc7e3a9993007ba7':
// Mistral-7B-Instruct-v0.3
'Mistral V2 & V3'
,
'e4676cb56dffea7782fd3e2b577cfaf1e123537e6ef49b3ec7caa6c095c62272':
// Mistral-Nemo-Instruct-2407
'Mistral V3-Tekken'
,
'3c4ad5fa60dd8c7ccdf82fa4225864c903e107728fcaf859fa6052cb80c92ee9':
// Mistral-Large-Instruct-2411
'Mistral V7'
,
'3934d199bfe5b6fab5cba1b5f8ee475e8d5738ac315f21cb09545b4e665cc005':
// Mistral Small 24B
'Mistral V7'
,
// Gemma
'ecd6ae513fe103f0eb62e8ab5bfa8d0fe45c1074fa398b089c93a7e70c15cfd6':
// gemma-2-9b-it
// gemma-2-27b-it
'Gemma 2'
,
'87fa45af6cdc3d6a9e4dd34a0a6848eceaa73a35dcfe976bd2946a5822a38bf3':
// gemma-2-2b-it
'Gemma 2'
,
'7de1c58e208eda46e9c7f86397df37ec49883aeece39fb961e0a6b24088dd3c4':
// gemma-3
'Gemma 2'
,
// Cohere
'3b54f5c219ae1caa5c0bb2cdc7c001863ca6807cf888e4240e8739fa7eb9e02e':
// command-r-08-2024
'Command R'
,
// Tulu
'ac7498a36a719da630e99d48e6ebc4409de85a77556c2b6159eeb735bcbd11df':
// Tulu-3-8B
// Tulu-3-70B
'Tulu'
,
// DeepSeek V2.5
'54d400beedcd17f464e10063e0577f6f798fa896266a912d8a366f8a2fcc0bca':
'DeepSeek-V2.5'
,
// DeepSeek R1
'b6835114b7303ddd78919a82e4d9f7d8c26ed0d7dfc36beeb12d524f6144eab1':
'DeepSeek-V2.5'
,
// THUDM-GLM 4
'854b703e44ca06bdb196cc471c728d15dbab61e744fe6cdce980086b61646ed1':
'GLM-4'
,
// Kimi K2, ...
'aab20feb9bc6881f941ea649356130ffbc4943b3c2577c0991e1fba90de5a0fc':
'Moonshot AI'
,
// gpt-oss (unsloth)
'70da0d2348e40aaf8dad05f04a316835fd10547bd7e3392ce337e4c79ba91c01':
'OpenAI Harmony'
,
// gpt-oss (ggml-org)
'a4c9919cbbd4acdd51ccffe22da049264b1b73e59055fa58811a99efbd7c8146':
'OpenAI Harmony'
,
};
const substr_derivations = [
['Moonshot AI', ['<|im_user|>user<|im_middle|>', '<|im_assistant|>assistant<|im_middle|>', '<|im_end|>']],
['OpenAI Harmony', ['<|start|>user<|message|>', '<|start|>assistant<|channel|>final<|message|>', '<|end|>']],
// Generic cases
['ChatML', ['<|im_start|>user', '<|im_start|>assistant', '<|im_end|>']],
];
const parse_derivation = derivation => (typeof derivation === 'string') ? {
'context': derivation,
'instruct': derivation,
} : derivation;
const not_found = { context: null, instruct: null };
export async function deriveTemplatesFromChatTemplate(chat_template, hash) {
if (chat_template.trim() === '') {
console.log('Missing chat template.');
return not_found;
}
if (hash in hash_derivations) {
return parse_derivation(hash_derivations[hash]);
}
// heuristics
for (const [derivation, substr] of substr_derivations) {
if ([substr].flat().every(str => chat_template.includes(str))) {
return parse_derivation(derivation);
}
}
console.warn(`Unknown chat template hash: ${hash} for [${chat_template}]`);
return not_found;
}
export async function bindModelTemplates(power_user, online_status) {
if (online_status === 'no_connection') {
return false;
}
const chatTemplateHash = power_user.chat_template_hash;
const bindModelTemplates = power_user.model_templates_mappings[online_status]
?? power_user.model_templates_mappings[chatTemplateHash]
?? {};
const bindingsMatch = bindModelTemplates
&& power_user.context.preset == bindModelTemplates['context']
&& (!power_user.instruct.enabled || power_user.instruct.preset === bindModelTemplates['instruct']);
const bound = [];
if (bindingsMatch) {
// unmap current preset
delete power_user.model_templates_mappings[chatTemplateHash];
delete power_user.model_templates_mappings[online_status];
toastr.info(t`Context preset for ${online_status} will use defaults when loaded the next time.`);
} else {
if (power_user.context_derived) {
if (power_user.context.preset !== bindModelTemplates['context']) {
bound.push(`${power_user.context.preset} context preset`);
// toastr.info(`Bound ${power_user.context.preset} preset to currently loaded model and all models that share its chat template.`);
// map current preset to current chat template hash
bindModelTemplates['context'] = power_user.context.preset;
}
} else {
toastr.warning(t`Note: Context derivation is disabled. Not including context preset.`);
}
if (power_user.instruct.enabled) {
if (power_user.instruct_derived) {
if (power_user.instruct.preset !== bindModelTemplates['instruct']) {
bound.push(`${power_user.instruct.preset} instruct preset`);
bindModelTemplates['instruct'] = power_user.instruct.preset;
}
} else {
toastr.warning(t`Note: Instruct derivation is disabled. Not including instruct preset.`);
}
}
if (bound.length == 0) {
toastr.warning(t`No applicable presets available.`);
return false;
}
toastr.info(t`Bound ${online_status} to ${bound.join(', ')}.`);
if (!online_status.startsWith('koboldcpp/ggml-model-')) {
power_user.model_templates_mappings[online_status] = bindModelTemplates;
}
if (chatTemplateHash !== '') {
power_user.model_templates_mappings[chatTemplateHash] = bindModelTemplates;
}
}
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
/**
* Common debounce timeout values to use with `debounce` calls.
* @readonly
* @enum {number}
*/
export const debounce_timeout = {
/** [100 ms] For ultra-fast responses, typically for keypresses or executions that might happen multiple times in a loop or recursion. */
quick: 100,
/** [200 ms] Slightly slower than quick, but still very responsive. */
short: 200,
/** [300 ms] Default time for general use, good balance between responsiveness and performance. */
standard: 300,
/** [1.000 ms] For situations where the function triggers more intensive tasks. */
relaxed: 1000,
/** [5 sec] For delayed tasks, like auto-saving or completing batch operations that need a significant pause. */
extended: 5000,
};
/**
* Used as an ephemeral key in message extra metadata.
* When set, the message will be excluded from generation
* prompts without affecting the number of chat messages,
* which is needed to preserve world info timed effects.
*/
export const IGNORE_SYMBOL = Symbol.for('ignore');
/**
* Common video file extensions. Should be the same as supported by Gemini.
* https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats
*/
export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg'];
/**
* Known generation triggers that can be passed to Generate function.
*/
export const GENERATION_TYPE_TRIGGERS = [
'normal',
'continue',
'impersonate',
'swipe',
'regenerate',
'quiet',
];
/**
* Known injection IDs and helper functions for system extensions handling.
*/
export const inject_ids = {
STORY_STRING: '__STORY_STRING__',
QUIET_PROMPT: 'QUIET_PROMPT',
DEPTH_PROMPT: 'DEPTH_PROMPT',
DEPTH_PROMPT_INDEX: (index) => `DEPTH_PROMPT_${index}`,
CUSTOM_WI_DEPTH: 'customDepthWI',
CUSTOM_WI_DEPTH_ROLE: (depth, role) => `customDepthWI_${depth}_${role}`,
CUSTOM_WI_OUTLET: (key) => `customWIOutlet_${key}`,
};
export const COMETAPI_IGNORE_PATTERNS = [
// Image generation models
'dall-e', 'dalle', 'midjourney', 'mj_', 'stable-diffusion', 'sd-',
'flux-', 'playground-v', 'ideogram', 'recraft-', 'black-forest-labs',
'/recraft-v3', 'recraftv3', 'stability-ai/', 'sdxl',
// Audio generation models
'suno_', 'tts', 'whisper',
// Video generation models
'runway', 'luma_', 'luma-', 'veo', 'kling_', 'minimax_video', 'hunyuan-t1',
// Utility models
'embedding', 'search-gpts', 'files_retrieve', 'moderation',
];
/**
* @readonly
* @enum {string}
*/
export const MEDIA_SOURCE = {
API: 'api',
UPLOAD: 'upload',
GENERATED: 'generated',
CAPTIONED: 'captioned',
};
/**
* @readonly
* @enum {string}
*/
export const MEDIA_DISPLAY = {
LIST: 'list',
GALLERY: 'gallery',
};
/**
* @readonly
* @enum {string}
*/
export const IMAGE_OVERSWIPE = {
GENERATE: 'generate',
ROLLOVER: 'rollover',
};
/**
* @readonly
*/
export const MEDIA_TYPE = {
getFromMime: (/** @type {string} */ mimeType) => {
if (mimeType.startsWith('image/')) {
return MEDIA_TYPE.IMAGE;
}
if (mimeType.startsWith('video/')) {
return MEDIA_TYPE.VIDEO;
}
if (mimeType.startsWith('audio/')) {
return MEDIA_TYPE.AUDIO;
}
return null;
},
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
/**
* Bitwise flag-style media request types.
* @readonly
* @enum {number}
*/
export const MEDIA_REQUEST_TYPE = {
IMAGE: 0b001,
VIDEO: 0b010,
AUDIO: 0b100,
};
/**
* Scroll behavior options when appending media to messages.
* @readonly
* @enum {string}
*/
export const SCROLL_BEHAVIOR = {
NONE: 'none',
KEEP: 'keep',
ADJUST: 'adjust',
};
/**
* @readonly
* @enum {string}
*/
export const OVERSWIPE_BEHAVIOR = {
/** The overswipe right chevron will not be displayed. */
NONE: 'none',
/** An overswipe will loop to the first swipe. */
LOOP: 'loop',
/** Pristine greetings will loop, and chevrons will always be shown: https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3557893373 */
PRISTINE_GREETING: 'pristine_greeting',
/** If chat tree is enabled, then an overswipe will allow the user to edit the message before starting a new generation. */
EDIT_GENERATE: 'edit_generate',
/** This is the default behavior on character messages. */
REGENERATE: 'regenerate',
};
/**
* @readonly
* @enum {string}
*/
export const SWIPE_DIRECTION = {
LEFT: 'left',
RIGHT: 'right',
};
/**
* @readonly
* @enum {string}
*/
export const SWIPE_SOURCE = {
DELETE: 'delete',
KEYBOARD: 'keyboard',
BACK: 'back',
AUTO_SWIPE: 'auto_swipe',
};
/**
* @readonly
* @enum {string}
*/
export const SWIPE_STATE = {
NONE: 'none',
SWIPING: 'swiping',
EDITING: 'editing',
};

View File

@@ -0,0 +1,606 @@
import { getPresetManager } from './preset-manager.js';
import { extractJsonFromData, extractMessageFromData, getGenerateUrl, getRequestHeaders, name1, name2 } from '../script.js';
import { getTextGenServer, createTextGenGenerationData, setting_names, textgenerationwebui_settings } from './textgen-settings.js';
import { extractReasoningFromData } from './reasoning.js';
import { formatInstructModeChat, formatInstructModePrompt, getInstructStoppingSequences } from './instruct-mode.js';
import { getStreamingReply, tryParseStreamingError, createGenerationParameters, settingsToUpdate, oai_settings } from './openai.js';
import EventSourceStream from './sse-stream.js';
// #region Type Definitions
/**
* @typedef {Object} TextCompletionRequestBase
* @property {boolean?} [stream=false] - Whether to stream the response
* @property {number} max_tokens - Maximum number of tokens to generate
* @property {string} [model] - Optional model name
* @property {string} api_type - Type of API to use
* @property {string} [api_server] - Optional API server URL
* @property {number} [temperature] - Optional temperature parameter
* @property {number} [min_p] - Optional min_p parameter
*/
/**
* @typedef {Object} TextCompletionPayloadBase
* @property {boolean?} [stream=false] - Whether to stream the response
* @property {string} prompt - The text prompt for completion
* @property {number} max_tokens - Maximum number of tokens to generate
* @property {number} max_new_tokens - Alias for max_tokens
* @property {string} [model] - Optional model name
* @property {string} api_type - Type of API to use
* @property {string} api_server - API server URL
* @property {number} [temperature] - Optional temperature parameter
*/
/** @typedef {Record<string, any> & TextCompletionPayloadBase} TextCompletionPayload */
/**
* @typedef {Object} ChatCompletionMessage
* @property {string} [name] - The name of the message author (optional)
* @property {string} role - The role of the message author (e.g., "user", "assistant", "system")
* @property {string} content - The content of the message
*/
/**
* @typedef {Object} ChatCompletionPayloadBase
* @property {boolean?} [stream=false] - Whether to stream the response
* @property {ChatCompletionMessage[]} messages - Array of chat messages
* @property {string} [model] - Optional model name to use for completion
* @property {string} chat_completion_source - Source provider
* @property {number} max_tokens - Maximum number of tokens to generate
* @property {number} [temperature] - Optional temperature parameter for response randomness
* @property {string} [custom_url] - Optional custom URL
* @property {string} [reverse_proxy] - Optional reverse proxy URL
* @property {string} [proxy_password] - Optional proxy password
* @property {string} [custom_prompt_post_processing] - Optional custom prompt post-processing
*/
/** @typedef {Record<string, any> & ChatCompletionPayloadBase} ChatCompletionPayload */
/**
* @typedef {Object} ExtractedData
* @property {string} content - Extracted content.
* @property {string} reasoning - Extracted reasoning.
*/
/**
* @typedef {Object} StreamResponse
* @property {string} text - Generated text.
* @property {string[]} swipes - Generated swipes
* @property {Object} state - Generated state
* @property {string?} [state.reasoning] - Generated reasoning
* @property {string?} [state.image] - Generated image
*/
// #endregion
/**
* Creates & sends a text completion request.
*/
export class TextCompletionService {
static TYPE = 'textgenerationwebui';
/**
* @param {Record<string, any> & TextCompletionRequestBase & {prompt: string}} custom
* @returns {TextCompletionPayload}
*/
static createRequestData({ stream = false, prompt, max_tokens, model, api_type, api_server, temperature, min_p, ...props }) {
const payload = {
stream,
prompt,
max_tokens,
max_new_tokens: max_tokens,
model,
api_type,
api_server: api_server ?? getTextGenServer(api_type),
temperature,
min_p,
...props,
};
// Remove undefined values to avoid API errors
Object.keys(payload).forEach(key => {
if (payload[key] === undefined) {
delete payload[key];
}
});
return payload;
}
/**
* Sends a text completion request to the specified server
* @param {TextCompletionPayload} data Request data
* @param {boolean?} extractData Extract message from the response. Default true
* @param {AbortSignal?} signal
* @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
* @throws {Error}
*/
static async sendRequest(data, extractData = true, signal = null) {
if (!data.stream) {
const response = await fetch(getGenerateUrl(this.TYPE), {
method: 'POST',
headers: getRequestHeaders(),
cache: 'no-cache',
body: JSON.stringify(data),
signal: signal ?? new AbortController().signal,
});
const json = await response.json();
if (!response.ok || json.error) {
throw new Error(String(json.error?.message || 'Response not OK'));
}
if (!extractData) {
return json;
}
return {
content: extractMessageFromData(json, this.TYPE),
reasoning: extractReasoningFromData(json, {
mainApi: this.TYPE,
textGenType: data.api_type,
ignoreShowThoughts: true,
}),
};
}
const response = await fetch('/api/backends/text-completions/generate', {
method: 'POST',
headers: getRequestHeaders(),
cache: 'no-cache',
body: JSON.stringify(data),
signal: signal ?? new AbortController().signal,
});
if (!response.ok) {
const text = await response.text();
tryParseStreamingError(response, text, { quiet: true });
throw new Error(`Got response status ${response.status}`);
}
const eventStream = new EventSourceStream();
response.body.pipeThrough(eventStream);
const reader = eventStream.readable.getReader();
return async function* streamData() {
let text = '';
const swipes = [];
const state = { reasoning: '' };
while (true) {
const { done, value } = await reader.read();
if (done) return;
if (value.data === '[DONE]') return;
tryParseStreamingError(response, value.data, { quiet: true });
let data = JSON.parse(value.data);
if (data?.choices?.[0]?.index > 0) {
const swipeIndex = data.choices[0].index - 1;
swipes[swipeIndex] = (swipes[swipeIndex] || '') + data.choices[0].text;
} else {
const newText = data?.choices?.[0]?.text || data?.content || '';
text += newText;
state.reasoning += data?.choices?.[0]?.reasoning ?? '';
}
yield { text, swipes, state };
}
};
}
/**
* Return a formatted prompt string given an array of messages, a chosen instruct preset, and instruct settings.
* @param {(ChatCompletionMessage & {ignoreInstruct?: boolean})[]} prompt An array of messages
* @param {InstructSettings|string} instructPreset Either the name of an instruct preset or the instruct preset object itself.
* @param {Partial<InstructSettings>} instructSettings Optional instruct settings
*/
static constructPrompt(prompt, instructPreset, instructSettings) {
// InstructPreset may either be a name or itself a preset
if (typeof instructPreset === 'string') {
const instructPresetManager = getPresetManager('instruct');
instructPreset = instructPresetManager?.getCompletionPresetByName(instructPreset);
}
// Clone the preset to avoid modifying the original
instructPreset = structuredClone(instructPreset);
if (instructSettings) { // apply any additional settings
Object.assign(instructPreset, instructSettings);
}
// Make the type check shut up. We 100% don't have a string here.
if (typeof instructPreset === 'string') {
return;
}
// Format messages using instruct formatting
const formattedMessages = [];
const prefillActive = prompt.length > 0 ? prompt[prompt.length - 1].role === 'assistant' : false;
for (const message of prompt) {
let messageContent = message.content;
if (!message.ignoreInstruct) {
const isLastMessage = message === prompt[prompt.length - 1];
// This complicated logic means:
// 1. If prefill is not active, format all messages
// 2. If prefill is active, format all messages except the last one
if (!isLastMessage || !prefillActive) {
messageContent = formatInstructModeChat(
message.name ?? message.role,
message.content,
message.role === 'user',
message.role === 'system',
undefined,
name1, // for macros
name2, // for macros
undefined,
instructPreset,
);
}
// Add prompt formatting for the last message.
// e.g. "<|im_start|>assistant"
if (isLastMessage) {
let last_line = formatInstructModePrompt(
'assistant', // for sequences using {{name}}
false, // not an impersonation
prefillActive ? message.content : undefined, // if using prefill, last message is the prefill
name1, // for macros
name2, // for macros
true, // quiet
false,
instructPreset,
);
if (prefillActive) { // content is the prefilled message
if (last_line.endsWith('\n') && !message.content.endsWith('\n')) {
last_line = last_line.slice(0, -1); // remove newline after prefill if it's not in the prefill itself
}
messageContent = last_line;
} else { // append last line to content (e.g. "<|im_start|>assistant:")
messageContent += last_line;
}
}
}
formattedMessages.push(messageContent);
}
return formattedMessages.join('');
}
/**
* Process and send a text completion request with optional preset & instruct
* @param {TextCompletionPayload} requestData
* @param {Object} options - Configuration options
* @param {string?} [options.presetName] - Name of the preset to use for generation settings
* @param {string?} [options.instructName] - Name of instruct preset for message formatting
* @param {Partial<InstructSettings>?} [options.instructSettings] - Override instruct settings
* @param {boolean} extractData - Whether to extract structured data from response
* @param {AbortSignal?} [signal]
* @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
* @throws {Error}
*/
static async processRequest(requestData, options = {}, extractData = true, signal = null) {
const { presetName, instructName } = options;
// remove any undefined params in given request data
requestData = this.createRequestData(requestData);
/** @type {InstructSettings | undefined} */
let instructPreset;
const prompt = requestData.prompt;
// Handle instruct formatting if requested
if (Array.isArray(prompt)) {
if (instructName) {
const instructPresetManager = getPresetManager('instruct');
instructPreset = instructPresetManager?.getCompletionPresetByName(instructName);
if (instructPreset) {
requestData.prompt = this.constructPrompt(prompt, instructPreset, options.instructSettings);
const stoppingStrings = getInstructStoppingSequences({ customInstruct: instructPreset, useStopStrings: false });
requestData.stop = stoppingStrings;
requestData.stopping_strings = stoppingStrings;
} else {
console.warn(`Instruct preset "${instructName}" not found, using basic formatting`);
requestData.prompt = prompt.map(x => x.content).join('\n\n');
}
} else {
requestData.prompt = prompt.map(x => x.content).join('\n\n');
}
} else if (typeof prompt === 'string') {
requestData.prompt = prompt;
}
// Apply generation preset if specified
if (presetName) {
const presetManager = getPresetManager(this.TYPE);
if (presetManager) {
const preset = presetManager.getCompletionPresetByName(presetName);
if (preset) {
// Convert preset to payload and merge with custom data
requestData = this.presetToGeneratePayload(preset, {}, requestData);
} else {
console.warn(`Preset "${presetName}" not found, continuing with default settings`);
}
} else {
console.warn('Preset manager not found, continuing with default settings');
}
}
const response = await this.sendRequest(requestData, extractData, signal);
// Remove stopping strings from the end
if (!requestData.stream && extractData) {
/** @type {ExtractedData} */
// @ts-ignore
const extractedData = response;
let message = extractedData.content;
message = message.replace(/[^\S\r\n]+$/gm, '');
if (requestData.stopping_strings) {
for (const stoppingString of requestData.stopping_strings) {
if (stoppingString.length) {
for (let j = stoppingString.length; j > 0; j--) {
if (message.slice(-j) === stoppingString.slice(0, j)) {
message = message.slice(0, -j);
break;
}
}
}
}
}
if (instructPreset) {
[
instructPreset.stop_sequence,
instructPreset.input_sequence,
].forEach(sequence => {
if (sequence?.trim()) {
const index = message.indexOf(sequence);
if (index !== -1) {
message = message.substring(0, index);
}
}
});
[
instructPreset.output_sequence,
instructPreset.last_output_sequence,
].forEach(sequences => {
if (sequences) {
sequences.split('\n')
.filter(line => line.trim() !== '')
.forEach(line => {
message = message.replaceAll(line, '');
});
}
});
}
extractedData.content = message;
}
return response;
}
/**
* Converts a preset to a valid text completion payload.
* Only supports temperature.
* @param {Object} preset - The preset configuration
* @param {Object} overridePreset - Additional parameters to override preset values
* @param {Object} overridePayload - Additional parameters to override payload values
* @returns {Object} - Formatted payload for text completion API
*/
static presetToGeneratePayload(preset, overridePreset = {}, overridePayload = {}) {
if (!preset || typeof preset !== 'object') {
throw new Error('Invalid preset: must be an object');
}
// apply preset overrides
preset = { ...preset, ...overridePreset };
// Only take fields from the preset specified in setting_names to use as TextCompletionSettings
const settings = structuredClone(textgenerationwebui_settings);
for (const [key, value] of Object.entries(preset)) {
if (!setting_names.includes(key)) continue;
settings[key] = value;
}
// convert to a generation payload
const payload = createTextGenGenerationData(settings, overridePayload.model, overridePayload.prompt, preset.genamt);
// apply overrides
return this.createRequestData({ ...payload, ...overridePayload });
}
}
/**
* Creates & sends a chat completion request.
*/
export class ChatCompletionService {
static TYPE = 'openai';
/**
* @param {ChatCompletionPayload} custom
* @returns {ChatCompletionPayload}
*/
static createRequestData({ stream = false, messages, model, chat_completion_source, max_tokens, temperature, custom_url, reverse_proxy, proxy_password, custom_prompt_post_processing, ...props }) {
const payload = {
stream,
messages,
model,
chat_completion_source,
max_tokens,
temperature,
custom_url,
reverse_proxy,
proxy_password,
custom_prompt_post_processing,
use_sysprompt: true,
...props,
};
// Remove undefined values to avoid API errors
Object.keys(payload).forEach(key => {
if (payload[key] === undefined) {
delete payload[key];
}
});
return payload;
}
/**
* Sends a chat completion request
* @param {ChatCompletionPayload} data Request data
* @param {boolean?} extractData Extract message from the response. Default true
* @param {AbortSignal?} signal Abort signal
* @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
* @throws {Error}
*/
static async sendRequest(data, extractData = true, signal = null) {
const response = await fetch('/api/backends/chat-completions/generate', {
method: 'POST',
headers: getRequestHeaders(),
cache: 'no-cache',
body: JSON.stringify(data),
signal: signal ?? new AbortController().signal,
});
if (!data.stream) {
const json = await response.json();
if (!response.ok || json.error) {
throw new Error(String(json.error?.message || 'Response not OK'));
}
if (!extractData) {
return json;
}
const result = {
content: extractMessageFromData(json, this.TYPE),
reasoning: extractReasoningFromData(json, {
mainApi: this.TYPE,
textGenType: data.chat_completion_source,
ignoreShowThoughts: true,
}),
};
// Try parse JSON
if (data.json_schema) {
result.content = JSON.parse(extractJsonFromData(json, { mainApi: this.TYPE, chatCompletionSource: data.chat_completion_source }));
}
return result;
}
if (!response.ok) {
const text = await response.text();
tryParseStreamingError(response, text, { quiet: true });
throw new Error(`Got response status ${response.status}`);
}
const eventStream = new EventSourceStream();
response.body.pipeThrough(eventStream);
const reader = eventStream.readable.getReader();
return async function* streamData() {
let text = '';
const swipes = [];
const state = { reasoning: '', image: '' };
while (true) {
const { done, value } = await reader.read();
if (done) return;
const rawData = value.data;
if (rawData === '[DONE]') return;
tryParseStreamingError(response, rawData, { quiet: true });
const parsed = JSON.parse(rawData);
const reply = getStreamingReply(parsed, state, {
chatCompletionSource: data.chat_completion_source,
overrideShowThoughts: true,
});
if (Array.isArray(parsed?.choices) && parsed?.choices?.[0]?.index > 0) {
const swipeIndex = parsed.choices[0].index - 1;
swipes[swipeIndex] = (swipes[swipeIndex] || '') + reply;
} else {
text += reply;
}
yield { text, swipes: swipes, state };
}
};
}
/**
* Process and send a chat completion request with optional preset
* @param {ChatCompletionPayload} requestData - payload data, overriding preset if given
* @param {Object} options - Configuration options
* @param {string?} [options.presetName] - Name of the preset to use for generation settings
* @param {boolean} [extractData=true] - Whether to extract structured data from response
* @param {AbortSignal?} [signal] - Abort signal
* @returns {Promise<ExtractedData | (() => AsyncGenerator<StreamResponse>)>} If not streaming, returns extracted data; if streaming, returns a function that creates an AsyncGenerator
* @throws {Error}
*/
static async processRequest(requestData, options, extractData = true, signal = null) {
const { presetName } = options;
requestData = this.createRequestData(requestData);
// Apply generation preset if specified
if (presetName) {
const presetManager = getPresetManager(this.TYPE);
if (presetManager) {
const preset = presetManager.getCompletionPresetByName(presetName);
if (preset) {
// Convert preset to payload and merge with custom parameters
requestData = await this.presetToGeneratePayload(preset, {}, requestData);
} else {
console.warn(`Preset "${presetName}" not found, continuing with default settings`);
}
} else {
console.warn('Preset manager not found, continuing with default settings');
}
}
return await this.sendRequest(requestData, extractData, signal);
}
/**
* Converts a preset to a valid chat completion payload
* Only supports temperature.
* @param {Object} preset - The preset configuration
* @param {Object} overridePreset - Additional parameters to override preset values
* @param {Object} overridePayload - Additional parameters to override payload values
* @returns {Promise<any>} - Formatted payload for chat completion API
*/
static async presetToGeneratePayload(preset, overridePreset = {}, overridePayload = {}) {
if (!preset || typeof preset !== 'object') {
throw new Error('Invalid preset: must be an object');
}
// apply preset overrides
preset = { ...preset, ...overridePreset };
// Fix any fields before converting to settings
preset.bias_preset_selected = preset.bias_presets !== undefined ? preset.bias_preset_selected : undefined; // presets might have bias_preset_selected but not bias_presets, but settings need both or neither.
// Convert from preset to ChatCompletionSettings
const settings = structuredClone(oai_settings);
for (const [key, value] of Object.entries(preset)) {
const settingToUpdate = settingsToUpdate[key];
if (!settingToUpdate) continue;
settings[settingToUpdate[1]] = value;
}
// Ensure api-url is properly applied for all sources that accept it
['custom_url', 'vertexai_region', 'zai_endpoint'].forEach(field => {
// The order is: connection profile => CC preset => CC settings
overridePayload[field] = overridePayload[field] || settings[field] || oai_settings[field];
});
// Convert from settings to generation payload
const data = await createGenerationParameters(settings, overridePayload.model, 'quiet', overridePayload.messages);
const payload = data.generate_data;
// apply overrides
return this.createRequestData({ ...payload, ...overridePayload });
}
}

View File

@@ -0,0 +1,405 @@
import { getRequestHeaders } from '../script.js';
import { VIDEO_EXTENSIONS } from './constants.js';
import { t } from './i18n.js';
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
import { renderTemplateAsync } from './templates.js';
import { humanFileSize, timestampToMoment } from './utils.js';
/**
* @typedef {object} DataMaidReportResult
* @property {import('../../src/endpoints/data-maid.js').DataMaidSanitizedReport} report - The sanitized report of the Data Maid.
* @property {string} token - The token to use for the Data Maid report.
*/
/**
* Data Maid Dialog class for managing the cleanup dialog interface.
*/
class DataMaidDialog {
constructor() {
this.token = null;
this.container = null;
this.isScanning = false;
this.DATA_MAID_CATEGORIES = {
files: {
name: t`Files`,
description: t`Files that are not associated with chat messages or Data Bank. WILL DELETE MANUAL UPLOADS!`,
},
images: {
name: t`Images`,
description: t`Images that are not associated with chat messages. WILL DELETE MANUAL UPLOADS!`,
},
chats: {
name: t`Chats`,
description: t`Chat files associated with deleted characters.`,
},
groupChats: {
name: t`Group Chats`,
description: t`Chat files associated with deleted groups.`,
},
avatarThumbnails: {
name: t`Avatar Thumbnails`,
description: t`Thumbnails for avatars of missing or deleted characters.`,
},
backgroundThumbnails: {
name: t`Background Thumbnails`,
description: t`Thumbnails for missing or deleted backgrounds.`,
},
personaThumbnails: {
name: t`Persona Thumbnails`,
description: t`Thumbnails for missing or deleted personas.`,
},
chatBackups: {
name: t`Chat Backups`,
description: t`Automatically generated chat backups.`,
},
settingsBackups: {
name: t`Settings Backups`,
description: t`Automatically generated settings backups.`,
},
};
}
/**
* Returns a promise that resolves to the Data Maid report.
* @returns {Promise<DataMaidReportResult>}
* @private
*/
async getReport() {
const response = await fetch('/api/data-maid/report', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
});
if (!response.ok) {
throw new Error(`Error fetching Data Maid report: ${response.statusText}`);
}
return await response.json();
}
/**
* Finalizes the Data Maid process by sending a request to the server.
* @returns {Promise<void>}
* @private
*/
async finalize() {
const response = await fetch('/api/data-maid/finalize', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ token: this.token }),
});
if (!response.ok) {
throw new Error(`Error finalizing Data Maid: ${response.statusText}`);
}
}
/**
* Sets up the dialog UI elements and event listeners.
* @private
*/
async setupDialogUI() {
const template = await renderTemplateAsync('dataMaidDialog');
this.container = document.createElement('div');
this.container.classList.add('dataMaidDialogContainer');
this.container.innerHTML = template;
const startButton = this.container.querySelector('.dataMaidStartButton');
startButton.addEventListener('click', () => this.handleScanClick());
}
/**
* Handles the scan button click event.
* @private
*/
async handleScanClick() {
if (this.isScanning) {
toastr.warning(t`The scan is already running. Please wait for it to finish.`);
return;
}
try {
const resultsList = this.container.querySelector('.dataMaidResultsList');
resultsList.innerHTML = '';
this.showSpinner();
this.isScanning = true;
const report = await this.getReport();
this.hideSpinner();
await this.renderReport(report, resultsList);
this.token = report.token;
} catch (error) {
this.hideSpinner();
toastr.error(t`An error has occurred. Check the console for details.`);
console.error('Error generating Data Maid report:', error);
} finally {
this.isScanning = false;
}
}
/**
* Shows the loading spinner and hides the placeholder.
* @private
*/
showSpinner() {
const spinner = this.container.querySelector('.dataMaidSpinner');
const placeholder = this.container.querySelector('.dataMaidPlaceholder');
placeholder.classList.add('displayNone');
spinner.classList.remove('displayNone');
}
/**
* Hides the loading spinner.
* @private
*/
hideSpinner() {
const spinner = this.container.querySelector('.dataMaidSpinner');
spinner.classList.add('displayNone');
}
/**
* Renders the Data Maid report into the results list.
* @param {DataMaidReportResult} report
* @param {Element} resultsList
* @private
*/
async renderReport(report, resultsList) {
for (const [prop, data] of Object.entries(this.DATA_MAID_CATEGORIES)) {
const category = await this.renderCategory(prop, data.name, data.description, report.report[prop]);
if (!category) {
continue;
}
resultsList.appendChild(category);
}
this.displayEmptyPlaceholder();
}
/**
* Displays a placeholder message if no items are found in the results list.
* @private
*/
displayEmptyPlaceholder() {
const resultsList = this.container.querySelector('.dataMaidResultsList');
if (resultsList.children.length === 0) {
const placeholder = this.container.querySelector('.dataMaidPlaceholder');
placeholder.classList.remove('displayNone');
placeholder.textContent = t`No items found to clean up. Come back later!`;
}
}
/**
* Renders a single Data Maid category into a DOM element.
* @param {string} prop Property name for the category
* @param {string} name Name of the category
* @param {string} description Description of the category
* @param {import('../../src/endpoints/data-maid.js').DataMaidSanitizedRecord[]} items List of items in the category
* @return {Promise<Element|null>} A promise that resolves to a DOM element containing the rendered category
* @private
*/
async renderCategory(prop, name, description, items) {
if (!Array.isArray(items) || items.length === 0) {
return null;
}
const viewModel = {
name: name,
description: description,
totalSize: humanFileSize(items.reduce((sum, item) => sum + item.size, 0)),
totalItems: items.length,
items: items.sort((a, b) => b.mtime - a.mtime).map(item => ({
...item,
size: humanFileSize(item.size),
date: timestampToMoment(item.mtime).format('L LT'),
})),
};
const template = await renderTemplateAsync('dataMaidCategory', viewModel);
const categoryElement = document.createElement('div');
categoryElement.innerHTML = template;
categoryElement.querySelectorAll('.dataMaidItemView').forEach(button => {
button.addEventListener('click', async () => {
const item = button.closest('.dataMaidItem');
const hash = item?.getAttribute('data-hash');
const itemName = items.find(i => i.hash === hash)?.name;
if (hash) {
await this.view(prop, hash, itemName);
}
});
});
categoryElement.querySelectorAll('.dataMaidItemDownload').forEach(button => {
button.addEventListener('click', async () => {
const item = button.closest('.dataMaidItem');
const hash = item?.getAttribute('data-hash');
if (hash) {
await this.download(items, hash);
}
});
});
categoryElement.querySelectorAll('.dataMaidDeleteAll').forEach(button => {
button.addEventListener('click', async (event) => {
event.stopPropagation();
const confirm = await Popup.show.confirm(t`Are you sure?`, t`This will permanently delete all files in this category. THIS CANNOT BE UNDONE!`);
if (!confirm) {
return;
}
const hashes = items.map(item => item.hash).filter(hash => hash);
await this.delete(hashes);
categoryElement.remove();
this.displayEmptyPlaceholder();
});
});
categoryElement.querySelectorAll('.dataMaidItemDelete').forEach(button => {
button.addEventListener('click', async () => {
const item = button.closest('.dataMaidItem');
const hash = item?.getAttribute('data-hash');
if (hash) {
const confirm = await Popup.show.confirm(t`Are you sure?`, t`This will permanently delete the file. THIS CANNOT BE UNDONE!`);
if (!confirm) {
return;
}
if (await this.delete([hash])) {
item.remove();
items.splice(items.findIndex(i => i.hash === hash), 1);
if (items.length === 0) {
categoryElement.remove();
this.displayEmptyPlaceholder();
}
}
}
});
});
return categoryElement;
}
/**
* Constructs the URL for viewing an item by its hash.
* @param {string} hash Hash of the item to view
* @returns {string} URL to view the item
* @private
*/
getViewUrl(hash) {
return `/api/data-maid/view?hash=${encodeURIComponent(hash)}&token=${encodeURIComponent(this.token)}`;
}
/**
* Downloads an item by its hash.
* @param {import('../../src/endpoints/data-maid.js').DataMaidSanitizedRecord[]} items List of items in the category
* @param {string} hash Hash of the item to download
* @private
*/
async download(items, hash) {
const item = items.find(i => i.hash === hash);
if (!item) {
return;
}
const url = this.getViewUrl(hash);
const a = document.createElement('a');
a.href = url;
a.download = item?.name || hash;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/**
* Opens the item view for a specific hash.
* @param {string} prop Property name for the category
* @param {string} hash Item hash to view
* @param {string} name Name of the item to view
* @private
*/
async view(prop, hash, name) {
const url = this.getViewUrl(hash);
const isImage = ['images', 'avatarThumbnails', 'backgroundThumbnails'].includes(prop);
const element = isImage
? await this.getViewElement(url, name)
: await this.getTextViewElement(url);
await callGenericPopup(element, POPUP_TYPE.DISPLAY, '', { large: true, wide: true });
}
/**
* Deletes an item by its file path hash.
* @param {string[]} hashes Hashes of items to delete
* @return {Promise<boolean>} True if the deletion was successful, false otherwise
* @private
*/
async delete(hashes) {
try {
const response = await fetch('/api/data-maid/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ hashes: hashes, token: this.token }),
});
if (!response.ok) {
throw new Error(`Error deleting item: ${response.statusText}`);
}
return true;
} catch (error) {
console.error('Error deleting item:', error);
return false;
}
}
/**
* Gets a media element for viewing images or videos.
* @param {string} url View URL
* @param {string} name Name of the file
* @returns {Promise<HTMLElement>} Image element
* @private
*/
async getViewElement(url, name) {
const isVideo = VIDEO_EXTENSIONS.includes(name.split('.').pop());
const mediaElement = document.createElement(isVideo ? 'video' : 'img');
if (mediaElement instanceof HTMLVideoElement) {
mediaElement.controls = true;
}
mediaElement.src = url;
mediaElement.classList.add('dataMaidImageView');
return mediaElement;
}
/**
* Gets an iframe element for viewing text content.
* @param {string} url View URL
* @returns {Promise<HTMLTextAreaElement>} Frame element
* @private
*/
async getTextViewElement(url) {
const response = await fetch(url);
const text = await response.text();
const element = document.createElement('textarea');
element.classList.add('dataMaidTextView');
element.readOnly = true;
element.textContent = text;
return element;
}
/**
* Opens the Data Maid dialog and handles the interaction.
*/
async open() {
await this.setupDialogUI();
await callGenericPopup(this.container, POPUP_TYPE.TEXT, '', { wide: true, large: true });
if (this.token) {
await this.finalize();
}
}
}
export function initDataMaid() {
const dataMaidButton = document.getElementById('data_maid_button');
if (!dataMaidButton) {
console.warn('Data Maid button not found');
return;
}
dataMaidButton.addEventListener('click', () => new DataMaidDialog().open());
}

View File

@@ -0,0 +1,66 @@
import { throttle } from './utils.js';
export function initDomHandlers() {
handleInputWheel();
}
/**
* Trap mouse wheel inside of focused number inputs to prevent scrolling their containers.
* Instead of firing wheel events, manually update both slider and input values.
* This also makes wheel work inside Firefox.
*/
function handleInputWheel() {
const minInterval = 25; // ms
/**
* Update input and slider values based on wheel delta
* @param {HTMLInputElement} input The number input element
* @param {HTMLInputElement|null} slider The associated range input element, if any
* @param {number} deltaY The wheel deltaY value
*/
function updateValue(input, slider, deltaY) {
const currentValue = parseFloat(input.value);
const step = parseFloat(input.step);
const min = parseFloat(input.min);
const max = parseFloat(input.max);
// Sanity checks before trying to calculate new value
if (isNaN(currentValue) || isNaN(step) || step <= 0 || deltaY === 0) return;
// Calculate new value based on wheel movement delta (negative = up, positive = down)
let newValue = currentValue + (deltaY > 0 ? -step : step);
// Ensure it's a multiple of step
newValue = Math.round(newValue / step) * step;
// Ensure it's within the min and max range (NaN-aware)
newValue = !isNaN(min) ? Math.max(newValue, min) : newValue;
newValue = !isNaN(max) ? Math.min(newValue, max) : newValue;
// Simple fix for floating point precision issues
newValue = Math.round(newValue * 1e10) / 1e10;
// Update both input and slider values
input.value = newValue.toString();
if (slider) slider.value = newValue.toString();
// Trigger input event (just ONE) to update any listeners
const inputEvent = new Event('input', { bubbles: true });
input.dispatchEvent(inputEvent);
}
const updateValueThrottled = throttle(updateValue, minInterval);
document.addEventListener('wheel', (e) => {
// Try to carefully narrow down if we even need to fire this handler
const input = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
if (input && input.type === 'number' && input.hasAttribute('step')) {
const parent = input.closest('.range-block-range-and-counter') ?? input.closest('div') ?? input.parentElement;
const slider = /** @type {HTMLInputElement} */ (parent?.querySelector('input[type="range"]'));
// Stop propagation for either target
if (e.target === input || (slider && e.target === slider)) {
e.stopPropagation();
e.preventDefault();
updateValueThrottled(input, slider, e.deltaY);
}
}
}, { passive: false });
}

107
data/st-core-scripts/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,198 @@
/** @type {CSSStyleSheet} */
let dynamicStyleSheet = null;
/** @type {CSSStyleSheet} */
let dynamicExtensionStyleSheet = null;
/**
* An observer that will check if any new stylesheets are added to the head
* @type {MutationObserver}
*/
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type !== 'childList') return;
mutation.addedNodes.forEach(node => {
if (node instanceof HTMLLinkElement && node.tagName === 'LINK' && node.rel === 'stylesheet') {
node.addEventListener('load', () => {
try {
applyDynamicFocusStyles(node.sheet);
} catch (e) {
console.warn('Failed to process new stylesheet:', e);
}
});
}
});
});
});
/**
* Generates dynamic focus styles based on the given stylesheet, taking its hover styles as reference
*
* @param {CSSStyleSheet} styleSheet - The stylesheet to process
* @param {object} [options] - Optional configuration options
* @param {boolean} [options.fromExtension=false] - Indicates if the styles are from an extension
*/
function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
/** @typedef {{ type: 'media'|'supports'|'container', conditionText: string }} WrapperCond */
/** @type {{baseSelector: string, rule: CSSStyleRule, wrappers: WrapperCond[]}[]} */
const hoverRules = [];
/** @type {Set<string>} */
const focusRules = new Set();
const PLACEHOLDER = ':__PLACEHOLDER__';
/**
* Builds a stable signature string for a chain of wrapper conditions so we can distinguish
* identical selectors under different contexts (e.g., different @media queries)
* @param {WrapperCond[]} wrappers
* @returns {string}
*/
function wrapperSignature(wrappers) {
return wrappers.map(w => `${w.type}:${w.conditionText}`).join(';');
}
/**
* Processes the CSS rules and separates selectors for hover and focus
* @param {CSSRuleList} rules - The CSS rules to process
* @param {WrapperCond[]} wrappers - Current chain of wrapper conditions (@media/@supports/etc.)
*/
function processRules(rules, wrappers = []) {
Array.from(rules).forEach(rule => {
if (rule instanceof CSSImportRule) {
// Make sure that @import rules are processed recursively
// If the @import has media conditions, treat them as wrappers as well
/** @type {WrapperCond[]} */
const extra = (rule.media && rule.media.mediaText) ? [{ type: 'media', conditionText: rule.media.mediaText }] : [];
processImportedStylesheet(rule.styleSheet, [...wrappers, ...extra]);
} else if (rule instanceof CSSStyleRule) {
// Separate multiple selectors on a rule
const selectors = rule.selectorText.split(',').map(s => s.trim());
// We collect all hover and focus rules to be able to later decide which hover rules don't have a matching focus rule
selectors.forEach(selector => {
const isHover = selector.includes(':hover'), isFocus = selector.includes(':focus');
if (isHover && isFocus) {
// We currently do nothing here. Rules containing both hover and focus are very specific and should never be automatically touched
}
else if (isHover) {
const baseSelector = selector.replace(/:hover/g, PLACEHOLDER).trim();
hoverRules.push({ baseSelector, rule, wrappers: [...wrappers] });
} else if (isFocus) {
// We need to make sure that we remember all existing :focus, :focus-within and :focus-visible rules
const baseSelector = selector.replace(/:focus(-within|-visible)?/g, PLACEHOLDER).trim();
focusRules.add(`${baseSelector}|${wrapperSignature(wrappers)}`);
}
});
} else if (rule instanceof CSSMediaRule) {
// Recursively process nested @media rules
processRules(rule.cssRules, [...wrappers, { type: 'media', conditionText: rule.conditionText }]);
} else if (rule instanceof CSSSupportsRule) {
// Recursively process nested @supports rules
processRules(rule.cssRules, [...wrappers, { type: 'supports', conditionText: rule.conditionText }]);
} else if (rule instanceof window.CSSContainerRule) {
// Recursively process nested @container rules (if supported by the browser)
// Note: conditionText contains the query like "(min-width: 300px)" or "style(color)"
// Using 'container' as the type ensures uniqueness separate from @media/@supports
processRules(rule.cssRules, [...wrappers, { type: 'container', conditionText: rule.conditionText }]);
}
});
}
/**
* Processes the CSS rules of an imported stylesheet recursively
* @param {CSSStyleSheet} sheet - The imported stylesheet to process
* @param {WrapperCond[]} wrappers - Wrapper conditions inherited from (at)import media
*/
function processImportedStylesheet(sheet, wrappers = []) {
if (sheet && sheet.cssRules) {
processRules(sheet.cssRules, wrappers);
}
}
processRules(styleSheet.cssRules, []);
/** @type {CSSStyleSheet} */
let targetStyleSheet = null;
// Now finally create the dynamic focus rules
hoverRules.forEach(({ baseSelector, rule, wrappers }) => {
if (!focusRules.has(`${baseSelector}|${wrapperSignature(wrappers)}`)) {
// Only initialize the dynamic stylesheet if needed
targetStyleSheet ??= getDynamicStyleSheet({ fromExtension });
// The closest keyboard-equivalent to :hover styling is utilizing the :focus-visible rule from modern browsers.
// It let's the browser decide whether a focus highlighting is expected and makes sense.
// So we take all :hover rules that don't have a manually defined focus rule yet, and create their
// :focus-visible counterpart, which will make the styling work the same for keyboard and mouse.
// If something like :focus-within or a more specific selector like `.blah:has(:focus-visible)` for elements inside,
// it should be manually defined in CSS.
const focusSelector = rule.selectorText.replace(/:hover/g, ':focus-visible');
let focusRule = `${focusSelector} { ${rule.style.cssText} }`;
// Wrap the generated rule into the same @media/@supports/@container chain (if any)
if (wrappers.length > 0) {
// Build nested blocks from outermost to innermost
// Example: @media (x) { @supports (y) { <rule> } }
focusRule = wrappers.reduceRight((inner, w) => {
if (w.type === 'media') return `@media ${w.conditionText} { ${inner} }`;
if (w.type === 'supports') return `@supports ${w.conditionText} { ${inner} }`;
if (w.type === 'container') return `@container ${w.conditionText} { ${inner} }`;
return inner;
}, focusRule);
}
try {
targetStyleSheet.insertRule(focusRule, targetStyleSheet.cssRules.length);
} catch (e) {
console.warn('Failed to insert focus rule:', e);
}
}
});
}
/**
* Retrieves the stylesheet that should be used for dynamic rules
*
* @param {object} options - The options object
* @param {boolean} [options.fromExtension=false] - Indicates whether the rules are coming from extensions
* @return {CSSStyleSheet} The dynamic stylesheet
*/
function getDynamicStyleSheet({ fromExtension = false } = {}) {
if (fromExtension) {
if (!dynamicExtensionStyleSheet) {
const styleSheetElement = document.createElement('style');
styleSheetElement.setAttribute('id', 'dynamic-extension-styles');
document.head.appendChild(styleSheetElement);
dynamicExtensionStyleSheet = styleSheetElement.sheet;
}
return dynamicExtensionStyleSheet;
} else {
if (!dynamicStyleSheet) {
const styleSheetElement = document.createElement('style');
styleSheetElement.setAttribute('id', 'dynamic-styles');
document.head.appendChild(styleSheetElement);
dynamicStyleSheet = styleSheetElement.sheet;
}
return dynamicStyleSheet;
}
}
/**
* Initializes dynamic styles for ST
*/
export function initDynamicStyles() {
// Start observing the head for any new added stylesheets
observer.observe(document.head, {
childList: true,
subtree: true,
});
// Process all stylesheets on initial load
Array.from(document.styleSheets).forEach(sheet => {
try {
applyDynamicFocusStyles(sheet, { fromExtension: sheet.href?.toLowerCase().includes('scripts/extensions') == true });
} catch (e) {
console.warn('Failed to process stylesheet on initial load:', e);
}
});
}

View File

@@ -0,0 +1,116 @@
import { EventEmitter } from '../lib/eventemitter.js';
export const event_types = {
APP_READY: 'app_ready',
EXTRAS_CONNECTED: 'extras_connected',
MESSAGE_SWIPED: 'message_swiped',
MESSAGE_SENT: 'message_sent',
MESSAGE_RECEIVED: 'message_received',
MESSAGE_EDITED: 'message_edited',
MESSAGE_DELETED: 'message_deleted',
MESSAGE_UPDATED: 'message_updated',
MESSAGE_FILE_EMBEDDED: 'message_file_embedded',
MESSAGE_REASONING_EDITED: 'message_reasoning_edited',
MESSAGE_REASONING_DELETED: 'message_reasoning_deleted',
MESSAGE_SWIPE_DELETED: 'message_swipe_deleted',
MORE_MESSAGES_LOADED: 'more_messages_loaded',
IMPERSONATE_READY: 'impersonate_ready',
CHAT_CHANGED: 'chat_id_changed',
GENERATION_AFTER_COMMANDS: 'GENERATION_AFTER_COMMANDS',
GENERATION_STARTED: 'generation_started',
GENERATION_STOPPED: 'generation_stopped',
GENERATION_ENDED: 'generation_ended',
SD_PROMPT_PROCESSING: 'sd_prompt_processing',
EXTENSIONS_FIRST_LOAD: 'extensions_first_load',
EXTENSION_SETTINGS_LOADED: 'extension_settings_loaded',
SETTINGS_LOADED: 'settings_loaded',
SETTINGS_UPDATED: 'settings_updated',
GROUP_UPDATED: 'group_updated',
MOVABLE_PANELS_RESET: 'movable_panels_reset',
SETTINGS_LOADED_BEFORE: 'settings_loaded_before',
SETTINGS_LOADED_AFTER: 'settings_loaded_after',
CHATCOMPLETION_SOURCE_CHANGED: 'chatcompletion_source_changed',
CHATCOMPLETION_MODEL_CHANGED: 'chatcompletion_model_changed',
OAI_PRESET_CHANGED_BEFORE: 'oai_preset_changed_before',
OAI_PRESET_CHANGED_AFTER: 'oai_preset_changed_after',
OAI_PRESET_EXPORT_READY: 'oai_preset_export_ready',
OAI_PRESET_IMPORT_READY: 'oai_preset_import_ready',
WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',
WORLDINFO_UPDATED: 'worldinfo_updated',
CHARACTER_EDITOR_OPENED: 'character_editor_opened',
CHARACTER_EDITED: 'character_edited',
CHARACTER_PAGE_LOADED: 'character_page_loaded',
CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE: 'character_group_overlay_state_change_before',
CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER: 'character_group_overlay_state_change_after',
USER_MESSAGE_RENDERED: 'user_message_rendered',
CHARACTER_MESSAGE_RENDERED: 'character_message_rendered',
FORCE_SET_BACKGROUND: 'force_set_background',
CHAT_DELETED: 'chat_deleted',
CHAT_CREATED: 'chat_created',
GROUP_CHAT_DELETED: 'group_chat_deleted',
GROUP_CHAT_CREATED: 'group_chat_created',
GENERATE_BEFORE_COMBINE_PROMPTS: 'generate_before_combine_prompts',
GENERATE_AFTER_COMBINE_PROMPTS: 'generate_after_combine_prompts',
GENERATE_AFTER_DATA: 'generate_after_data',
GROUP_MEMBER_DRAFTED: 'group_member_drafted',
GROUP_WRAPPER_STARTED: 'group_wrapper_started',
GROUP_WRAPPER_FINISHED: 'group_wrapper_finished',
WORLD_INFO_ACTIVATED: 'world_info_activated',
TEXT_COMPLETION_SETTINGS_READY: 'text_completion_settings_ready',
CHAT_COMPLETION_SETTINGS_READY: 'chat_completion_settings_ready',
CHAT_COMPLETION_PROMPT_READY: 'chat_completion_prompt_ready',
CHARACTER_FIRST_MESSAGE_SELECTED: 'character_first_message_selected',
// TODO: Naming convention is inconsistent with other events
CHARACTER_DELETED: 'characterDeleted',
CHARACTER_DUPLICATED: 'character_duplicated',
CHARACTER_RENAMED: 'character_renamed',
CHARACTER_RENAMED_IN_PAST_CHAT: 'character_renamed_in_past_chat',
/** @deprecated The event is aliased to STREAM_TOKEN_RECEIVED. */
SMOOTH_STREAM_TOKEN_RECEIVED: 'stream_token_received',
STREAM_TOKEN_RECEIVED: 'stream_token_received',
STREAM_REASONING_DONE: 'stream_reasoning_done',
FILE_ATTACHMENT_DELETED: 'file_attachment_deleted',
WORLDINFO_FORCE_ACTIVATE: 'worldinfo_force_activate',
OPEN_CHARACTER_LIBRARY: 'open_character_library',
ONLINE_STATUS_CHANGED: 'online_status_changed',
IMAGE_SWIPED: 'image_swiped',
CONNECTION_PROFILE_LOADED: 'connection_profile_loaded',
CONNECTION_PROFILE_CREATED: 'connection_profile_created',
CONNECTION_PROFILE_DELETED: 'connection_profile_deleted',
CONNECTION_PROFILE_UPDATED: 'connection_profile_updated',
TOOL_CALLS_PERFORMED: 'tool_calls_performed',
TOOL_CALLS_RENDERED: 'tool_calls_rendered',
CHARACTER_MANAGEMENT_DROPDOWN: 'charManagementDropdown',
SECRET_WRITTEN: 'secret_written',
SECRET_DELETED: 'secret_deleted',
SECRET_ROTATED: 'secret_rotated',
SECRET_EDITED: 'secret_edited',
PRESET_CHANGED: 'preset_changed',
PRESET_DELETED: 'preset_deleted',
PRESET_RENAMED: 'preset_renamed',
PRESET_RENAMED_BEFORE: 'preset_renamed_before',
MAIN_API_CHANGED: 'main_api_changed',
WORLDINFO_ENTRIES_LOADED: 'worldinfo_entries_loaded',
WORLDINFO_SCAN_DONE: 'worldinfo_scan_done',
MEDIA_ATTACHMENT_DELETED: 'media_attachment_deleted',
};
export const eventSource = new EventEmitter([event_types.APP_READY]);
if (typeof window !== 'undefined') {
Object.values(event_types).forEach(eventType => {
window.addEventListener('st:' + eventType, (e) => {
if (e.__fromEventEmitter) return;
eventSource._handlingWindowEvent = true;
try {
const args = Array.isArray(e.detail) ? e.detail : [e.detail];
eventSource.emit(eventType, ...args);
} catch (err) {
console.error('Error bridging window event to eventSource', err);
} finally {
eventSource._handlingWindowEvent = false;
}
});
});
}

View File

@@ -0,0 +1,321 @@
import { disableExtension, enableExtension, extension_settings, extensionNames } from './extensions.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { equalsIgnoreCaseAndAccents, isFalseBoolean, isTrueBoolean } from './utils.js';
/**
* @param {'enable' | 'disable' | 'toggle'} action - The action to perform on the extension
* @typedef {import('./slash-commands/SlashCommand.js').NamedArguments | import('./slash-commands/SlashCommand.js').NamedArgumentsCapture} NamedArgumentsAssignment
* @returns {(args: NamedArgumentsAssignment, extensionName: string | SlashCommandClosure) => Promise<string>}
*/
function getExtensionActionCallback(action) {
return async (args, extensionName) => {
if (args?.reload instanceof SlashCommandClosure) throw new Error('\'reload\' argument cannot be a closure.');
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
if (!extensionName) {
toastr.warning(`Extension name must be provided as an argument to ${action} this extension.`);
return '';
}
const reload = !isFalseBoolean(args?.reload?.toString());
const internalExtensionName = findExtension(extensionName);
if (!internalExtensionName) {
toastr.warning(`Extension ${extensionName} does not exist.`);
return '';
}
const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
if (action === 'enable' && isEnabled) {
toastr.info(`Extension ${extensionName} is already enabled.`);
return internalExtensionName;
}
if (action === 'disable' && !isEnabled) {
toastr.info(`Extension ${extensionName} is already disabled.`);
return internalExtensionName;
}
if (action === 'toggle') {
action = isEnabled ? 'disable' : 'enable';
}
if (reload) {
toastr.info(`${action.charAt(0).toUpperCase() + action.slice(1)}ing extension ${extensionName} and reloading...`);
// Clear input, so it doesn't stay because the command didn't "finish",
// and wait for a bit to both show the toast and let the clear bubble through.
$('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles: true }));
await new Promise(resolve => setTimeout(resolve, 100));
}
if (action === 'enable') {
await enableExtension(internalExtensionName, reload);
} else {
await disableExtension(internalExtensionName, reload);
}
toastr.success(`Extension ${extensionName} ${action}d.`);
console.info(`Extension ${action}ed: ${extensionName}`);
if (!reload) {
console.info('Reload not requested, so page needs to be reloaded manually for changes to take effect.');
}
return internalExtensionName;
};
}
/**
* Finds an extension by name, allowing omission of the "third-party/" prefix.
*
* @param {string} name - The name of the extension to find
* @returns {string?} - The matched extension name or undefined if not found
*/
function findExtension(name) {
return extensionNames.find(extName => {
return equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`);
});
}
/**
* Provides an array of SlashCommandEnumValue objects based on the extension names.
* Each object contains the name of the extension and a description indicating if it is a third-party extension.
*
* @returns {SlashCommandEnumValue[]} An array of SlashCommandEnumValue objects
*/
const extensionNamesEnumProvider = () => extensionNames.map(name => {
const isThirdParty = name.startsWith('third-party/');
if (isThirdParty) name = name.slice('third-party/'.length);
const description = isThirdParty ? 'third party extension' : null;
return new SlashCommandEnumValue(name, description, !isThirdParty ? enumTypes.name : enumTypes.enum);
});
export function registerExtensionSlashCommands() {
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'extension-enable',
callback: getExtensionActionCallback('enable'),
returns: 'The internal extension name',
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'reload',
description: 'Whether to reload the page after enabling the extension',
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Extension name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: extensionNamesEnumProvider,
forceEnum: true,
}),
],
helpString: `
<div>
Enables a specified extension.
</div>
<div>
By default, the page will be reloaded automatically, stopping any further commands.<br />
If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay disabled until refreshed.
The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code class="language-stscript">/extension-enable Summarize</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'extension-disable',
callback: getExtensionActionCallback('disable'),
returns: 'The internal extension name',
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'reload',
description: 'Whether to reload the page after disabling the extension',
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Extension name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: extensionNamesEnumProvider,
forceEnum: true,
}),
],
helpString: `
<div>
Disables a specified extension.
</div>
<div>
By default, the page will be reloaded automatically, stopping any further commands.<br />
If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay enabled until refreshed.
The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code class="language-stscript">/extension-disable Summarize</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'extension-toggle',
callback: async (args, extensionName) => {
if (args?.state instanceof SlashCommandClosure) throw new Error('\'state\' argument cannot be a closure.');
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
const action = isTrueBoolean(args?.state?.toString()) ? 'enable' :
isFalseBoolean(args?.state?.toString()) ? 'disable' :
'toggle';
return await getExtensionActionCallback(action)(args, extensionName);
},
returns: 'The internal extension name',
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'reload',
description: 'Whether to reload the page after toggling the extension',
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
SlashCommandNamedArgument.fromProps({
name: 'state',
description: 'Explicitly set the state of the extension (true to enable, false to disable). If not provided, the state will be toggled to the opposite of the current state.',
typeList: [ARGUMENT_TYPE.BOOLEAN],
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Extension name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: extensionNamesEnumProvider,
forceEnum: true,
}),
],
helpString: `
<div>
Toggles the state of a specified extension.
</div>
<div>
By default, the page will be reloaded automatically, stopping any further commands.<br />
If <code>reload=false</code> named argument is passed, the page will not be reloaded, and the extension will stay in its current state until refreshed.
The page either needs to be refreshed, or <code>/reload-page</code> has to be called.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code class="language-stscript">/extension-toggle Summarize</code></pre>
</li>
<li>
<pre><code class="language-stscript">/extension-toggle Summarize state=true</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'extension-state',
callback: async (_, extensionName) => {
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
const internalExtensionName = findExtension(extensionName);
if (!internalExtensionName) {
toastr.warning(`Extension ${extensionName} does not exist.`);
return '';
}
const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
return String(isEnabled);
},
returns: 'The state of the extension, whether it is enabled.',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Extension name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: extensionNamesEnumProvider,
forceEnum: true,
}),
],
helpString: `
<div>
Returns the state of a specified extension (true if enabled, false if disabled).
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code class="language-stscript">/extension-state Summarize</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'extension-exists',
aliases: ['extension-installed'],
callback: async (_, extensionName) => {
if (typeof extensionName !== 'string') throw new Error('Extension name must be a string. Closures or arrays are not allowed.');
const exists = findExtension(extensionName) !== undefined;
return exists ? 'true' : 'false';
},
returns: 'Whether the extension exists and is installed.',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Extension name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: extensionNamesEnumProvider,
}),
],
helpString: `
<div>
Checks if a specified extension exists.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code class="language-stscript">/extension-exists SillyTavern-LALib</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'reload-page',
callback: async () => {
toastr.info('Reloading the page...');
location.reload();
return '';
},
helpString: 'Reloads the current page. All further commands will not be processed.',
}));
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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