?} [options.tagElement=null] - Optionally a direct html element of the tag to be removed, so it can be removed from the UI
+ * @returns {boolean} Whether at least one tag was removed
+ */
+export function removeTagFromEntity(tag, entityId, { tagListSelector = null, tagElement = null } = {}) {
+ let result = false;
+ // Remove tag from the map
+ if (Array.isArray(entityId)) {
+ entityId.forEach((id) => result = removeTagFromMap(tag.id, id) || result);
+ } else {
+ result = removeTagFromMap(tag.id, entityId);
+ }
+
+ // Save and redraw
+ printCharactersDebounced();
+ saveSettingsDebounced();
+
+ // We don't reprint the lists, we can just remove the html elements from them.
+ if (tagListSelector) {
+ const $selector = (typeof tagListSelector === 'string') ? $(tagListSelector) : tagListSelector;
+ $selector.find(`.tag[id="${tag.id}"]`).remove();
+ }
+ if (tagElement) tagElement.remove();
+ $(`${getInlineListSelector()} .tag[id="${tag.id}"]`).remove();
+
+ return result;
+}
+
+/**
+ * Adds a tag from a given character. If no character is provided, adds it from the currently active one.
+ * @param {string} tagId - The id of the tag
+ * @param {string} characterId - The id/key of the character or group
+ * @returns {boolean} Whether the tag was added or not
+ */
function addTagToMap(tagId, characterId = null) {
const key = characterId !== null && characterId !== undefined ? getTagKeyForEntity(characterId) : getTagKey();
if (!key) {
- return;
+ return false;
}
if (!Array.isArray(tag_map[key])) {
tag_map[key] = [tagId];
+ return true;
}
else {
+ if (tag_map[key].includes(tagId))
+ return false;
+
tag_map[key].push(tagId);
tag_map[key] = tag_map[key].filter(onlyUnique);
+ return true;
}
}
+/**
+ * Removes a tag from a given character. If no character is provided, removes it from the currently active one.
+ * @param {string} tagId - The id of the tag
+ * @param {string} characterId - The id/key of the character or group
+ * @returns {boolean} Whether the tag was removed or not
+ */
function removeTagFromMap(tagId, characterId = null) {
const key = characterId !== null && characterId !== undefined ? getTagKeyForEntity(characterId) : getTagKey();
if (!key) {
- return;
+ return false;
}
if (!Array.isArray(tag_map[key])) {
tag_map[key] = [];
+ return false;
}
else {
const indexOf = tag_map[key].indexOf(tagId);
tag_map[key].splice(indexOf, 1);
+ return indexOf !== -1;
}
}
@@ -535,24 +624,7 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) {
const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters;
const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
- if (characterIds) {
- characterIds.forEach((characterId) => addTagToMap(tag.id, characterId));
- } else {
- addTagToMap(tag.id);
- }
-
- printCharactersDebounced();
- saveSettingsDebounced();
-
- // We should manually add the selected tag to the print tag function, so we cover places where the tag list did not automatically include it
- tagListOptions.addTag = tag;
-
- // add tag to the UI and internal map - we reprint so sorting and new markup is done correctly
- printTagList(listSelector, tagListOptions);
- const inlineSelector = getInlineListSelector();
- if (inlineSelector) {
- printTagList($(inlineSelector), tagListOptions);
- }
+ addTagToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions });
// need to return false to keep the input clear
return false;
@@ -635,6 +707,7 @@ function createNewTag(tagName) {
create_date: Date.now(),
};
tags.push(tag);
+ console.debug('Created new tag', tag.name, 'with id', tag.id);
return tag;
}
@@ -932,8 +1005,8 @@ function updateTagFilterIndicator() {
function onTagRemoveClick(event) {
event.stopPropagation();
- const tag = $(this).closest('.tag');
- const tagId = tag.attr('id');
+ const tagElement = $(this).closest('.tag');
+ const tagId = tagElement.attr('id');
// Check if we are inside the drilldown. If so, we call remove on the bogus folder
if ($(this).closest('.rm_tag_bogus_drilldown').length > 0) {
@@ -942,24 +1015,13 @@ function onTagRemoveClick(event) {
return;
}
+ const tag = tags.find(t => t.id === tagId);
+
// 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;
- tag.remove();
-
- if (characterIds) {
- characterIds.forEach((characterId) => removeTagFromMap(tagId, characterId));
- } else {
- removeTagFromMap(tagId);
- }
-
- $(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove();
-
- printCharactersDebounced();
- saveSettingsDebounced();
-
-
+ removeTagFromEntity(tag, characterIds, { tagElement: tagElement });
}
// @ts-ignore
@@ -985,7 +1047,7 @@ function onGroupCreateClick() {
export function applyTagsOnCharacterSelect() {
//clearTagsFilter();
- const chid = Number($(this).attr('chid'));
+ const chid = Number(this_chid);
printTagList($('#tagList'), { forEntityOrKey: chid, tagOptions: { removable: true } });
}
@@ -1461,14 +1523,200 @@ function printViewTagList(empty = true) {
}
}
+function registerTagsSlashCommands() {
+ /**
+ * Gets the key for char/group for a slash command. If none can be found, a toastr will be shown and null returned.
+ * @param {string?} [charName] The optionally provided char name
+ * @returns {string?} - The char/group key, or null if none found
+ */
+ function paraGetCharKey(charName) {
+ const entity = charName
+ ? (characters.find(x => x.name === charName) || groups.find(x => x.name == charName))
+ : (selected_group ? groups.find(x => x.id == selected_group) : characters[this_chid]);
+ const key = getTagKeyForEntity(entity);
+ if (!key) {
+ toastr.warning(`Character ${charName} not found.`);
+ return null;
+ }
+ return key;
+ }
+ /**
+ * Gets a tag by its name. Optionally can create the tag if it does not exist.
+ * @param {string} tagName - The name of the tag
+ * @param {object} options - Optional arguments
+ * @param {boolean} [options.allowCreate=false] - Whether a new tag should be created if no tag with the name exists
+ * @returns {Tag?} The tag, or null if not found
+ */
+ function paraGetTag(tagName, { allowCreate = false } = {}) {
+ if (!tagName) {
+ toastr.warning('Tag name must be provided.');
+ return null;
+ }
+ let tag = tags.find(t => t.name === tagName);
+ if (allowCreate && !tag) {
+ tag = createNewTag(tagName);
+ }
+ if (!tag) {
+ toastr.warning(`Tag ${tagName} not found.`);
+ return null;
+ }
+ return tag;
+ }
+
+ function updateTagsList() {
+ switch (menu_type) {
+ case 'characters':
+ printTagFilters(tag_filter_types.character);
+ printTagFilters(tag_filter_types.group_member);
+ break;
+ case 'character_edit':
+ applyTagsOnCharacterSelect();
+ break;
+ case 'group_edit':
+ select_group_chats(selected_group, true);
+ break;
+ }
+ }
+
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'tag-add',
+ returns: 'true/false - Whether the tag was added or was assigned already',
+ /** @param {{name: string}} namedArgs @param {string} tagName @returns {string} */
+ callback: ({ name }, tagName) => {
+ const key = paraGetCharKey(name);
+ if (!key) return 'false';
+ const tag = paraGetTag(tagName, { allowCreate: true });
+ if (!tag) return 'false';
+ const result = addTagToEntity(tag, key);
+ updateTagsList();
+ return String(result);
+ },
+ namedArgumentList: [
+ new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'),
+ ],
+ unnamedArgumentList: [
+ new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true),
+ ],
+ helpString: `
+
+ Adds a tag to the character. If no character is provided, it adds it to the current character ({{char}}
).
+ If the tag doesn't exist, it is created.
+
+
+ `,
+ }));
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'tag-remove',
+ returns: 'true/false - Whether the tag was removed or wasn\'t assigned already',
+ /** @param {{name: string}} namedArgs @param {string} tagName @returns {string} */
+ callback: ({ name }, tagName) => {
+ const key = paraGetCharKey(name);
+ if (!key) return 'false';
+ const tag = paraGetTag(tagName);
+ if (!tag) return 'false';
+ const result = removeTagFromEntity(tag, key);
+ updateTagsList();
+ return String(result);
+ },
+ namedArgumentList: [
+ new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'),
+ ],
+ unnamedArgumentList: [
+ new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true),
+ ],
+ helpString: `
+
+ Removes a tag from the character. If no character is provided, it removes it from the current character ({{char}}
).
+
+
+ `,
+ }));
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'tag-exists',
+ returns: 'true/false - Whether the given tag name is assigned to the character',
+ /** @param {{name: string}} namedArgs @param {string} tagName @returns {string} */
+ callback: ({ name }, tagName) => {
+ const key = paraGetCharKey(name);
+ if (!key) return 'false';
+ const tag = paraGetTag(tagName);
+ if (!tag) return 'false';
+ return String(tag_map[key].includes(tag.id));
+ },
+ namedArgumentList: [
+ new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'),
+ ],
+ unnamedArgumentList: [
+ new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true),
+ ],
+ helpString: `
+
+ Checks whether the given tag is assigned to the character. If no character is provided, it checks the current character ({{char}}
).
+
+
+ `,
+ }));
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'tag-list',
+ returns: 'Comma-separated list of all assigned tags',
+ /** @param {{name: string}} namedArgs @returns {string} */
+ callback: ({ name }) => {
+ const key = paraGetCharKey(name);
+ if (!key) return '';
+ const tags = getTagsList(key);
+ return tags.map(x => x.name).join(', ');
+ },
+ namedArgumentList: [
+ new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'),
+ ],
+ helpString: `
+
+ Lists all assigned tags of the character. If no character is provided, it uses the current character ({{char}}
).
+
+ Note that there is no special handling for tags containing commas, they will be printed as-is.
+
+
+
Example:
+
+ -
+
/tag-list name="Chloe"
+ could return something like OC, scenario, edited, funny
+
+
+
+ `,
+ }));
+}
+
export function initTags() {
createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } });
createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } });
$(document).on('click', '#rm_button_create', onCharacterCreateClick);
$(document).on('click', '#rm_button_group_chats', onGroupCreateClick);
- $(document).on('click', '.character_select', applyTagsOnCharacterSelect);
- $(document).on('click', '.group_select', applyTagsOnGroupSelect);
$(document).on('click', '.tag_remove', onTagRemoveClick);
$(document).on('input', '.tag_input', onTagInput);
$(document).on('click', '.tags_view', onViewTagsListClick);
@@ -1479,6 +1727,7 @@ export function initTags() {
$(document).on('click', '.tag_view_backup', onTagsBackupClick);
$(document).on('click', '.tag_view_restore', onBackupRestoreClick);
eventSource.on(event_types.CHARACTER_DUPLICATED, copyTags);
+ eventSource.makeFirst(event_types.CHAT_CHANGED, () => selected_group ? applyTagsOnGroupSelect() : applyTagsOnCharacterSelect());
$(document).on('input', '#dialogue_popup input[name="auto_sort_tags"]', (evt) => {
const toggle = $(evt.target).is(':checked');
@@ -1506,4 +1755,6 @@ export function initTags() {
printCharactersDebounced();
}
}
+
+ registerTagsSlashCommands();
}
diff --git a/public/scripts/templates/welcome.html b/public/scripts/templates/welcome.html
index 6082b2cde..a950c5a11 100644
--- a/public/scripts/templates/welcome.html
+++ b/public/scripts/templates/welcome.html
@@ -11,9 +11,10 @@
Click
and select a Chat API.
- Click
and pick a character
+ Click
and pick a character.
+You can browse a list of bundled characters in the Download Extensions & Assets menu within
.
Confused or lost?
diff --git a/public/scripts/textgen-settings.js b/public/scripts/textgen-settings.js
index 55b4f1c19..42472c871 100644
--- a/public/scripts/textgen-settings.js
+++ b/public/scripts/textgen-settings.js
@@ -991,7 +991,7 @@ export function getTextGenModel() {
}
export function isJsonSchemaSupported() {
- return settings.type === TABBY && main_api === 'textgenerationwebui';
+ return [TABBY, LLAMACPP].includes(settings.type) && main_api === 'textgenerationwebui';
}
export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, isContinue, cfgValues, type) {
@@ -1065,7 +1065,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
'guidance_scale': cfgValues?.guidanceScale?.value ?? settings.guidance_scale ?? 1,
'negative_prompt': cfgValues?.negativePrompt ?? substituteParams(settings.negative_prompt) ?? '',
'grammar_string': settings.grammar_string,
- 'json_schema': settings.type === TABBY ? settings.json_schema : undefined,
+ 'json_schema': [TABBY, LLAMACPP].includes(settings.type) ? settings.json_schema : undefined,
// llama.cpp aliases. In case someone wants to use LM Studio as Text Completion API
'repeat_penalty': settings.rep_pen,
'tfs_z': settings.tfs,
@@ -1150,5 +1150,15 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
eventSource.emitAndWait(event_types.TEXT_COMPLETION_SETTINGS_READY, params);
+ // Grammar conflicts with with json_schema
+ if (settings.type === LLAMACPP) {
+ if (params.json_schema && Object.keys(params.json_schema).length > 0) {
+ delete params.grammar_string;
+ delete params.grammar;
+ } else {
+ delete params.json_schema;
+ }
+ }
+
return params;
}
diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js
index d21b36847..10a9cc47e 100644
--- a/public/scripts/world-info.js
+++ b/public/scripts/world-info.js
@@ -1,5 +1,5 @@
import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
-import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe } from './utils.js';
+import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean } from './utils.js';
import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { isMobile } from './RossAscends-mods.js';
@@ -548,6 +548,19 @@ function registerWorldInfoSlashCommands() {
return '';
}
+ if (typeof newEntryTemplate[field] === 'boolean') {
+ const isTrue = isTrueBoolean(value);
+ const isFalse = isFalseBoolean(value);
+
+ if (isTrue) {
+ value = String(true);
+ }
+
+ if (isFalse) {
+ value = String(false);
+ }
+ }
+
const fuse = new Fuse(entries, {
keys: [{ name: field, weight: 1 }],
includeScore: true,
@@ -1244,6 +1257,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none, fl
}
worldEntriesList.sortable({
+ items: '.world_entry',
delay: getSortableDelay(),
handle: '.drag-handle',
stop: async function (_event, _ui) {
diff --git a/public/style.css b/public/style.css
index 9d9e99006..71f885fe1 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2068,6 +2068,7 @@ input[type="file"] {
gap: 5px;
justify-content: center;
align-items: center;
+ flex-wrap: wrap;
}
.bulk_select_checkbox {
@@ -4892,4 +4893,4 @@ body:not(.movingUI) .drawer-content.maximized {
.regex-operator { color: #FFB6C1; } /* Light Pink */
.regex-flags { color: #98FB98; } /* Pale Green */
.regex-delimiter { font-weight: bold; color: #FF6961; } /* Pastel Red */
-.regex-highlight { color: #FAF8F6; } /* Pastel White */
\ No newline at end of file
+.regex-highlight { color: #FAF8F6; } /* Pastel White */
diff --git a/src/endpoints/content-manager.js b/src/endpoints/content-manager.js
index d91319051..c282dba36 100644
--- a/src/endpoints/content-manager.js
+++ b/src/endpoints/content-manager.js
@@ -7,7 +7,9 @@ const { getConfigValue, color } = require('../util');
const { jsonParser } = require('../express-common');
const writeFileAtomicSync = require('write-file-atomic').sync;
const contentDirectory = path.join(process.cwd(), 'default/content');
+const scaffoldDirectory = path.join(process.cwd(), 'default/scaffold');
const contentIndexPath = path.join(contentDirectory, 'index.json');
+const scaffoldIndexPath = path.join(scaffoldDirectory, 'index.json');
const characterCardParser = require('../character-card-parser.js');
const WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES = getConfigValue('whitelistImportDomains', []);
@@ -16,6 +18,8 @@ const WHITELIST_GENERIC_URL_DOWNLOAD_SOURCES = getConfigValue('whitelistImportDo
* @typedef {Object} ContentItem
* @property {string} filename
* @property {string} type
+ * @property {string} [name]
+ * @property {string|null} [folder]
*/
/**
@@ -48,9 +52,7 @@ const CONTENT_TYPES = {
*/
function getDefaultPresets(directories) {
try {
- const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
- const contentIndex = JSON.parse(contentIndexText);
-
+ const contentIndex = getContentIndex();
const presets = [];
for (const contentItem of contentIndex) {
@@ -112,8 +114,12 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
continue;
}
- contentLog.push(contentItem.filename);
- const contentPath = path.join(contentDirectory, contentItem.filename);
+ if (!contentItem.folder) {
+ console.log(`Content file ${contentItem.filename} has no parent folder`);
+ continue;
+ }
+
+ const contentPath = path.join(contentItem.folder, contentItem.filename);
if (!fs.existsSync(contentPath)) {
console.log(`Content file ${contentItem.filename} is missing`);
@@ -129,6 +135,7 @@ async function seedContentForUser(contentIndex, directories, forceCategories) {
const basePath = path.parse(contentItem.filename).base;
const targetPath = path.join(contentTarget, basePath);
+ contentLog.push(contentItem.filename);
if (fs.existsSync(targetPath)) {
console.log(`Content file ${contentItem.filename} already exists in ${contentTarget}`);
@@ -157,8 +164,7 @@ async function checkForNewContent(directoriesList, forceCategories = []) {
return;
}
- const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
- const contentIndex = JSON.parse(contentIndexText);
+ const contentIndex = getContentIndex();
let anyContentAdded = false;
for (const directories of directoriesList) {
@@ -179,6 +185,38 @@ async function checkForNewContent(directoriesList, forceCategories = []) {
}
}
+/**
+ * Gets combined content index from the content and scaffold directories.
+ * @returns {ContentItem[]} Array of content index
+ */
+function getContentIndex() {
+ const result = [];
+
+ if (fs.existsSync(scaffoldIndexPath)) {
+ const scaffoldIndexText = fs.readFileSync(scaffoldIndexPath, 'utf8');
+ const scaffoldIndex = JSON.parse(scaffoldIndexText);
+ if (Array.isArray(scaffoldIndex)) {
+ scaffoldIndex.forEach((item) => {
+ item.folder = scaffoldDirectory;
+ });
+ result.push(...scaffoldIndex);
+ }
+ }
+
+ if (fs.existsSync(contentIndexPath)) {
+ const contentIndexText = fs.readFileSync(contentIndexPath, 'utf8');
+ const contentIndex = JSON.parse(contentIndexText);
+ if (Array.isArray(contentIndex)) {
+ contentIndex.forEach((item) => {
+ item.folder = contentDirectory;
+ });
+ result.push(...contentIndex);
+ }
+ }
+
+ return result;
+}
+
/**
* Gets the target directory for the specified asset type.
* @param {ContentType} type Asset type