diff --git a/public/script.js b/public/script.js index 350b19e42..6be19fd49 100644 --- a/public/script.js +++ b/public/script.js @@ -163,8 +163,8 @@ import { getTagBlock, loadTagsSettings, printTagFilters, - getTagsList, - appendTagToList, + getTagKeyForEntity, + printTagList, createTagMapFromList, renameTagKey, importTags, @@ -803,8 +803,11 @@ let token; var PromptArrayItemForRawPromptDisplay; +/** The tag of the active character. (NOT the id) */ export let active_character = ''; +/** The tag of the active group. (Coincidentally also the id) */ export let active_group = ''; + export const entitiesFilter = new FilterHelper(debounce(printCharacters, 100)); export const personasFilter = new FilterHelper(debounce(getUserAvatars, 100)); @@ -877,12 +880,12 @@ export function setAnimationDuration(ms = null) { animation_duration = ms ?? ANIMATION_DURATION_DEFAULT; } -export function setActiveCharacter(character) { - active_character = character; +export function setActiveCharacter(entityOrKey) { + active_character = getTagKeyForEntity(entityOrKey); } -export function setActiveGroup(group) { - active_group = group; +export function setActiveGroup(entityOrKey) { + active_group = getTagKeyForEntity(entityOrKey); } /** @@ -1187,14 +1190,14 @@ function getEmptyBlock() { * @param {number} hidden Number of hidden characters */ function getHiddenBlock(hidden) { - const hiddenBlick = ` + const hiddenBlock = `

${hidden} ${hidden > 1 ? 'characters' : 'character'} hidden.

`; - return $(hiddenBlick); + return $(hiddenBlock); } function getCharacterBlock(item, id) { @@ -1233,9 +1236,8 @@ function getCharacterBlock(item, id) { } // Display inline tags - const tags = getTagsList(item.avatar); const tagsElement = template.find('.tags'); - tags.forEach(tag => appendTagToList(tagsElement, tag, {})); + printTagList(tagsElement, { forEntityOrKey: id }); // Add to the list return template; diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index d12e1599e..e1db7f77b 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -15,7 +15,7 @@ import { import { favsToHotswap } from './RossAscends-mods.js'; import { hideLoader, showLoader } from './loader.js'; import { convertCharacterToPersona } from './personas.js'; -import { createTagInput, getTagKeyForCharacter, tag_map } from './tags.js'; +import { createTagInput, getTagKeyForEntity, tag_map } from './tags.js'; // Utility object for popup messages. const popupMessage = { @@ -243,7 +243,7 @@ class BulkTagPopupHandler { */ static resetTags(characterIds) { characterIds.forEach((characterId) => { - const key = getTagKeyForCharacter(characterId); + const key = getTagKeyForEntity(characterId); if (key) tag_map[key] = []; }); diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js index 8c7d34c8c..3cadf8978 100644 --- a/public/scripts/RossAscends-mods.js +++ b/public/scripts/RossAscends-mods.js @@ -11,7 +11,6 @@ import { setActiveGroup, setActiveCharacter, getEntitiesList, - getThumbnailUrl, buildAvatarList, selectCharacterById, eventSource, @@ -27,7 +26,8 @@ import { } from './power-user.js'; import { LoadLocal, SaveLocal, LoadLocalBool } from './f-localStorage.js'; -import { selected_group, is_group_generating, getGroupAvatar, groups, openGroupById } from './group-chats.js'; +import { selected_group, is_group_generating, openGroupById } from './group-chats.js'; +import { getTagKeyForEntity } from './tags.js'; import { SECRET_KEYS, secret_state, @@ -248,8 +248,7 @@ export function RA_CountCharTokens() { async function RA_autoloadchat() { if (document.querySelector('#rm_print_characters_block .character_select') !== null) { // active character is the name, we should look it up in the character list and get the id - let active_character_id = Object.keys(characters).find(key => characters[key].avatar === active_character); - + const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) == active_character); if (active_character_id !== null) { await selectCharacterById(String(active_character_id)); } @@ -805,14 +804,14 @@ export function initRossMods() { // when a char is selected from the list, save their name as the auto-load character for next page load $(document).on('click', '.character_select', function () { - const characterId = $(this).find('.avatar').attr('title') || $(this).attr('title'); + const characterId = $(this).attr('chid') || $(this).data('id'); setActiveCharacter(characterId); setActiveGroup(null); saveSettingsDebounced(); }); $(document).on('click', '.group_select', function () { - const groupId = $(this).data('id') || $(this).attr('grid'); + const groupId = $(this).attr('grid') || $(this).data('id'); setActiveCharacter(null); setActiveGroup(groupId); saveSettingsDebounced(); diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js index 7aad2bad1..1cdfb8cfc 100644 --- a/public/scripts/group-chats.js +++ b/public/scripts/group-chats.js @@ -69,7 +69,7 @@ import { loadItemizedPrompts, animation_duration, } from '../script.js'; -import { appendTagToList, createTagMapFromList, getTagsList, applyTagsOnCharacterSelect, tag_map } from './tags.js'; +import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map } from './tags.js'; import { FILTER_TYPES, FilterHelper } from './filters.js'; export { @@ -546,9 +546,8 @@ export function getGroupBlock(group) { template.find('.group_select_block_list').append(namesList.join('')); // Display inline tags - const tags = getTagsList(group.id); const tagsElement = template.find('.tags'); - tags.forEach(tag => appendTagToList(tagsElement, tag, {})); + printTagList(tagsElement, { forEntityOrKey: group.id }); const avatar = getGroupAvatar(group); if (avatar) { @@ -579,7 +578,7 @@ function isValidImageUrl(url) { function getGroupAvatar(group) { if (!group) { - return $(`
`); + return $(`
`); } // if isDataURL or if it's a valid local file url if (isValidImageUrl(group.avatar_url)) { @@ -1185,9 +1184,8 @@ function getGroupCharacterBlock(character) { template.toggleClass('disabled', isGroupMemberDisabled(character.avatar)); // Display inline tags - const tags = getTagsList(character.avatar); const tagsElement = template.find('.tags'); - tags.forEach(tag => appendTagToList(tagsElement, tag, {})); + printTagList(tagsElement, { forEntityOrKey: characters.indexOf(character) }); if (!openGroupId) { template.find('[data-action="speak"]').hide(); @@ -1263,6 +1261,9 @@ function select_group_chats(groupId, skipAnimation) { selectRightMenuWithAnimation('rm_group_chats_block'); } + // render tags + printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } }); + // render characters list printGroupCandidates(); printGroupMembers(); diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 16b6162d1..d89d7353c 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -29,6 +29,7 @@ export { loadTagsSettings, printTagFilters, getTagsList, + printTagList, appendTagToList, createTagMapFromList, renameTagKey, @@ -308,12 +309,37 @@ function getTagKey() { return null; } -export function getTagKeyForCharacter(characterId = null) { - return characters[characterId]?.avatar; +/** + * Gets the tag key for any provided entity/id/key. If a valid tag key is provided, it just returns this. + * Robust method to find a valid tag key for any entity + * @param {object|number|string} entityOrKey An entity with id property (character, group, tag), or directly an id or tag key. + * @returns {string} The tag key that can be found. + */ +export function getTagKeyForEntity(entityOrKey) { + let x = entityOrKey; + + // If it's an object and has an 'id' property, we take this for further processing + if (typeof x === 'object' && x !== null && 'id' in x) { + x = x.id; + } + + // Next lets check if its a valid character or character id, so we can swith it to its tag + const character = characters.indexOf(x) > 0 ? x : characters[x]; + if (character) { + x = character.avatar; + } + + // We should hopefully have a key now. Let's check + if (x in tag_map) { + return x; + } + + // If none of the above, we cannot find a valid tag key + return undefined; } function addTagToMap(tagId, characterId = null) { - const key = getTagKey() ?? getTagKeyForCharacter(characterId); + const key = getTagKey() ?? getTagKeyForEntity(characterId); if (!key) { return; @@ -329,7 +355,7 @@ function addTagToMap(tagId, characterId = null) { } function removeTagFromMap(tagId, characterId = null) { - const key = getTagKey() ?? getTagKeyForCharacter(characterId); + const key = getTagKey() ?? getTagKeyForEntity(characterId); if (!key) { return; @@ -370,10 +396,6 @@ function selectTag(event, ui, listSelector) { // unfocus and clear the input $(event.target).val('').trigger('input'); - // add tag to the UI and internal map - appendTagToList(listSelector, tag, { removable: true }); - appendTagToList(getInlineListSelector(), tag, { removable: false }); - // Optional, check for multiple character ids being present. const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters; const characterIds = characterData ? JSON.parse(characterData).characterIds : null; @@ -385,6 +407,11 @@ function selectTag(event, ui, listSelector) { } saveSettingsDebounced(); + + // add tag to the UI and internal map - we reprint so sorting and new markup is done correctly + printTagList(listSelector, { tagOptions: { removable: true } }); + printTagList($(getInlineListSelector())); + printTagFilters(tag_filter_types.character); printTagFilters(tag_filter_types.group_member); @@ -458,18 +485,63 @@ function createNewTag(tagName) { return tag; } +/** + * @typedef {object} TagOptions + * @property {boolean} [removable=false] - Whether tags can be removed. + * @property {boolean} [selectable=false] - Whether tags can be selected. + * @property {function} [action=undefined] - Action to perform on tag interaction. + * @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags. + * @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists. + */ + +/** + * Prints the list of tags. + * @param {JQuery} element - The container element where the tags are to be printed. + * @param {object} [options] - Optional parameters for printing the tag list. + * @param {Array} [options.tags] Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. + * @param {object|number|string} [options.forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key. + * @param {boolean} [options.empty=true] - Whether the list should be initially empty. + * @param {function(object): function} [options.tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions. + * If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself. + * @param {TagOptions} [options.tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList") + */ +function printTagList(element, { tags = undefined, forEntityOrKey = undefined, empty = true, tagActionSelector = undefined, tagOptions = {} } = {}) { + const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey(); + const printableTags = tags ?? getTagsList(key); + + if (empty) { + $(element).empty(); + } + + for (const tag of printableTags) { + // If we have a custom action selector, we override that tag options for each tag + if (tagActionSelector && typeof tagActionSelector === 'function') { + const action = tagActionSelector(tag); + if (action && typeof action !== 'function') { + console.error('The action parameter must return a function for tag.', tag); + } else { + tagOptions.action = action; + } + } + + appendTagToList(element, tag, tagOptions); + } +} + /** * Appends a tag to the list element. - * @param {string} listElement List element selector. - * @param {object} tag Tag object. - * @param {TagOptions} options Options for the tag. - * @typedef {{removable?: boolean, selectable?: boolean, action?: function, isGeneralList?: boolean}} TagOptions + * @param {JQuery} listElement List element. + * @param {object} tag Tag object to append. + * @param {TagOptions} [options={}] - Options for tag behavior. * @returns {void} */ -function appendTagToList(listElement, tag, { removable, selectable, action, isGeneralList }) { +function appendTagToList(listElement, tag, { removable = false, selectable = false, action = undefined, isGeneralList = false, skipExistsCheck = false } = {}) { if (!listElement) { return; } + if (!skipExistsCheck && $(listElement).find(`.tag[id="${tag.id}"]`).length > 0) { + return; + } let tagElement = $('#tag_template .tag').clone(); tagElement.attr('id', tag.id); @@ -527,7 +599,7 @@ function onTagFilterClick(listElement) { if (isBogusFolder(existingTag)) { // Update bogus drilldown if ($(this).hasClass('selected')) { - appendTagToList('.rm_tag_controls .rm_tag_bogus_drilldown', existingTag, { removable: true, selectable: false, isGeneralList: false }); + appendTagToList($('.rm_tag_controls .rm_tag_bogus_drilldown'), existingTag, { removable: true }); } else { $(listElement).closest('.rm_tag_controls').find(`.rm_tag_bogus_drilldown .tag[id=${tagId}]`).remove(); } @@ -574,7 +646,6 @@ function toggleTagThreeState(element, { stateOverride = undefined, simulateClick return states[targetStateIndex]; } - function runTagFilters(listElement) { const tagIds = [...($(listElement).find('.tag.selected:not(.actionable)').map((_, el) => $(el).attr('id')))]; const excludedTagIds = [...($(listElement).find('.tag.excluded:not(.actionable)').map((_, el) => $(el).attr('id')))]; @@ -583,35 +654,29 @@ function runTagFilters(listElement) { } function printTagFilters(type = tag_filter_types.character) { + const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG)); const FILTER_SELECTOR = type === tag_filter_types.character ? CHARACTER_FILTER_SELECTOR : GROUP_FILTER_SELECTOR; - const selectedTagIds = [...($(FILTER_SELECTOR).find('.tag.selected').map((_, el) => $(el).attr('id')))]; $(FILTER_SELECTOR).empty(); $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown').empty(); + + // Print all action tags. (Exclude folder if that setting isn't chosen) + const actionTags = Object.values(ACTIONABLE_TAGS).filter(tag => power_user.bogus_folders || tag.id != ACTIONABLE_TAGS.FOLDER.id); + printTagList($(FILTER_SELECTOR), { empty: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } }); + + const inListActionTags = Object.values(InListActionable); + printTagList($(FILTER_SELECTOR), { empty: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } }); + const characterTagIds = Object.values(tag_map).flat(); const tagsToDisplay = tags .filter(x => characterTagIds.includes(x.id)) .sort(compareTagsForSort); + printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { selectable: true, isGeneralList: true } }); - for (const tag of Object.values(ACTIONABLE_TAGS)) { - if (!power_user.bogus_folders && tag.id == ACTIONABLE_TAGS.FOLDER.id) { - continue; - } + runTagFilters(FILTER_SELECTOR); - appendTagToList(FILTER_SELECTOR, tag, { removable: false, selectable: false, action: tag.action, isGeneralList: true }); - } - - for (const tag of Object.values(InListActionable)) { - appendTagToList(FILTER_SELECTOR, tag, { removable: false, selectable: false, action: tag.action, isGeneralList: true }); - } - for (const tag of tagsToDisplay) { - appendTagToList(FILTER_SELECTOR, tag, { removable: false, selectable: true, isGeneralList: true }); - if (tag.excluded) { - runTagFilters(FILTER_SELECTOR); - } - } - - for (const tagId of selectedTagIds) { - $(`${FILTER_SELECTOR} .tag[id="${tagId}"]`).trigger('click'); + // Simulate clicks on all "selected" tags when we reprint, otherwise their filter gets lost. "excluded" is persisted. + for (const tagId of filterData.selected) { + toggleTagThreeState($(`${FILTER_SELECTOR} .tag[id="${tagId}"]`), { stateOverride: FILTER_STATES.SELECTED, simulateClick: true }); } if (power_user.show_tag_filters) { @@ -679,36 +744,18 @@ function onCharacterCreateClick() { } function onGroupCreateClick() { - $('#groupTagList').empty(); - printTagFilters(tag_filter_types.character); - printTagFilters(tag_filter_types.group_member); + // Nothing to do here at the moment. Tags in group interface get automatically redrawn. } export function applyTagsOnCharacterSelect() { //clearTagsFilter(); const chid = Number($(this).attr('chid')); - const key = characters[chid].avatar; - const tags = getTagsList(key); - - $('#tagList').empty(); - - for (const tag of tags) { - appendTagToList('#tagList', tag, { removable: true }); - } + printTagList($('#tagList'), { forEntityOrKey: chid, tagOptions: { removable: true } }); } function applyTagsOnGroupSelect() { //clearTagsFilter(); - const key = $(this).attr('grid'); - const tags = getTagsList(key); - - $('#groupTagList').empty(); - printTagFilters(tag_filter_types.character); - printTagFilters(tag_filter_types.group_member); - - for (const tag of tags) { - appendTagToList('#groupTagList', tag, { removable: true }); - } + // Nothing to do here at the moment. Tags in group interface get automatically redrawn. } export function createTagInput(inputSelector, listSelector) { diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 5352fce5a..7774c7a3f 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -7,7 +7,7 @@ import { isMobile } from './RossAscends-mods.js'; import { FILTER_TYPES, FilterHelper } from './filters.js'; import { getTokenCount } from './tokenizers.js'; import { power_user } from './power-user.js'; -import { getTagKeyForCharacter } from './tags.js'; +import { getTagKeyForEntity } from './tags.js'; import { resolveVariable } from './variables.js'; export { @@ -2068,7 +2068,7 @@ async function checkWorldInfo(chat, maxContext) { } if (entry.characterFilter && entry.characterFilter?.tags?.length > 0) { - const tagKey = getTagKeyForCharacter(this_chid); + const tagKey = getTagKeyForEntity(this_chid); if (tagKey) { const tagMapEntry = context.tagMap[tagKey];