diff --git a/.gitignore b/.gitignore index d6a5061bb..fbef3d33f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ access.log public/css/user.css /plugins/ /data +/default/scaffold diff --git a/default/content/presets/openai/Default.json b/default/content/presets/openai/Default.json index dbf3b9619..132abeea0 100644 --- a/default/content/presets/openai/Default.json +++ b/default/content/presets/openai/Default.json @@ -231,6 +231,7 @@ "api_url_scale": "", "show_external_models": false, "assistant_prefill": "", + "assistant_impersonation": "", "human_sysprompt_message": "Let's get started. Please generate your response based on the information and instructions provided above.", "use_ai21_tokenizer": false, "use_google_tokenizer": false, diff --git a/default/content/settings.json b/default/content/settings.json index 3f4c25865..c04b88bfb 100644 --- a/default/content/settings.json +++ b/default/content/settings.json @@ -624,6 +624,7 @@ "show_external_models": false, "proxy_password": "", "assistant_prefill": "", + "assistant_impersonation": "", "use_ai21_tokenizer": false } } diff --git a/default/scaffold/README.md b/default/scaffold/README.md new file mode 100644 index 000000000..b1272cf8f --- /dev/null +++ b/default/scaffold/README.md @@ -0,0 +1,26 @@ +# Content Scaffolding + +Content files in this folder will be copied for all users (old and new) on the server startup. + +1. You **must** create an `index.json` file in `/default/scaffold` for it to work. The syntax is the same as for default content. +2. All file paths should be relative to `/default/scaffold`, the use of subdirectories is allowed. +3. Scaffolded files are copied first, so they override any of the default files (presets/settings/etc.) that have the same file name. + +## Example + +```json +[ + { + "filename": "themes/Midnight.json", + "type": "theme" + }, + { + "filename": "backgrounds/city.png", + "type": "background" + }, + { + "filename": "characters/Charlie.png", + "type": "character" + } +] +``` diff --git a/public/css/tags.css b/public/css/tags.css index 9075f659e..f9896d992 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -103,7 +103,8 @@ } #bulkTagsList, -#tagList .tag { +#tagList .tag, +#groupTagList .tag { opacity: 0.6; } diff --git a/public/index.html b/public/index.html index 0c2232ff1..a88c6839a 100644 --- a/public/index.html +++ b/public/index.html @@ -116,7 +116,7 @@
@@ -134,7 +134,7 @@

Chat Completion Presets

@@ -246,7 +246,7 @@
-
+
Temperature
@@ -1268,13 +1268,12 @@
-

-
+

+
-
- Dynamic Temperature + Dynamic Temperature

@@ -1459,7 +1458,7 @@

-
+

JSON Schema @@ -1734,6 +1733,8 @@
Assistant Prefill + Assistant Impersonation Prefill +
Chat Style:
@@ -4082,7 +4083,7 @@
- - Examples: + Examples: https://harrypotter.fandom.com/ or harrypotter diff --git a/public/scripts/extensions/attachments/mediawiki-scrape.html b/public/scripts/extensions/attachments/mediawiki-scrape.html index ff6b02eaf..e44316f3f 100644 --- a/public/scripts/extensions/attachments/mediawiki-scrape.html +++ b/public/scripts/extensions/attachments/mediawiki-scrape.html @@ -7,7 +7,7 @@ Don't include the page name! - Examples: + Examples: https://streetcat.wiki/index.php or https://tcrf.net diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 2cca2ea70..da527dd8b 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1020,12 +1020,12 @@ function parseLlmResponse(emotionResponse, labels) { const parsedEmotion = JSON.parse(emotionResponse); return parsedEmotion?.emotion ?? fallbackExpression; } catch { - const fuse = new Fuse([emotionResponse]); - for (const label of labels) { - const result = fuse.search(label); - if (result.length > 0) { - return label; - } + const fuse = new Fuse(labels, { includeScore: true }); + console.debug('Using fuzzy search in labels:', labels); + const result = fuse.search(emotionResponse); + if (result.length > 0) { + console.debug(`fuzzy search found: ${result[0].item} as closest for the LLM response:`, emotionResponse); + return result[0].item; } } diff --git a/public/scripts/extensions/quick-reply/src/QuickReply.js b/public/scripts/extensions/quick-reply/src/QuickReply.js index fed9bc1cf..33b36e6fb 100644 --- a/public/scripts/extensions/quick-reply/src/QuickReply.js +++ b/public/scripts/extensions/quick-reply/src/QuickReply.js @@ -342,6 +342,16 @@ export class QuickReply { message.addEventListener('scroll', (evt)=>{ updateScrollDebounced(); }); + /** @type {any} */ + const resizeListener = debounce((evt) => { + updateSyntax(); + updateScrollDebounced(evt); + if (document.activeElement == message) { + message.blur(); + message.focus(); + } + }); + window.addEventListener('resize', resizeListener); message.style.color = 'transparent'; message.style.background = 'transparent'; message.style.setProperty('text-shadow', 'none', 'important'); @@ -514,6 +524,8 @@ export class QuickReply { }); await popupResult; + + window.removeEventListener('resize', resizeListener); } else { warn('failed to fetch qrEditor template'); } diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 6e9236d8e..c14df4566 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -266,6 +266,7 @@ const default_settings = { show_external_models: false, proxy_password: '', assistant_prefill: '', + assistant_impersonation: '', human_sysprompt_message: default_claude_human_sysprompt_message, use_ai21_tokenizer: false, use_google_tokenizer: false, @@ -342,6 +343,7 @@ const oai_settings = { show_external_models: false, proxy_password: '', assistant_prefill: '', + assistant_impersonation: '', human_sysprompt_message: default_claude_human_sysprompt_message, use_ai21_tokenizer: false, use_google_tokenizer: false, @@ -1767,7 +1769,7 @@ async function sendOpenAIRequest(type, messages, signal) { generate_data['human_sysprompt_message'] = substituteParams(oai_settings.human_sysprompt_message); // Don't add a prefill on quiet gens (summarization) if (!isQuiet) { - generate_data['assistant_prefill'] = substituteParams(oai_settings.assistant_prefill); + generate_data['assistant_prefill'] = isImpersonate ? substituteParams(oai_settings.assistant_impersonation) : substituteParams(oai_settings.assistant_prefill); } } @@ -2760,6 +2762,7 @@ function loadOpenAISettings(data, settings) { oai_settings.show_external_models = settings.show_external_models ?? default_settings.show_external_models; oai_settings.proxy_password = settings.proxy_password ?? default_settings.proxy_password; oai_settings.assistant_prefill = settings.assistant_prefill ?? default_settings.assistant_prefill; + oai_settings.assistant_impersonation = settings.assistant_impersonation ?? default_settings.assistant_impersonation; oai_settings.human_sysprompt_message = settings.human_sysprompt_message ?? default_settings.human_sysprompt_message; oai_settings.image_inlining = settings.image_inlining ?? default_settings.image_inlining; oai_settings.inline_image_quality = settings.inline_image_quality ?? default_settings.inline_image_quality; @@ -2796,6 +2799,7 @@ function loadOpenAISettings(data, settings) { $('#api_url_scale').val(oai_settings.api_url_scale); $('#openai_proxy_password').val(oai_settings.proxy_password); $('#claude_assistant_prefill').val(oai_settings.assistant_prefill); + $('#claude_assistant_impersonation').val(oai_settings.assistant_impersonation); $('#claude_human_sysprompt_textarea').val(oai_settings.human_sysprompt_message); $('#openai_image_inlining').prop('checked', oai_settings.image_inlining); $('#openai_bypass_status_check').prop('checked', oai_settings.bypass_status_check); @@ -3115,6 +3119,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) { api_url_scale: settings.api_url_scale, show_external_models: settings.show_external_models, assistant_prefill: settings.assistant_prefill, + assistant_impersonation: settings.assistant_impersonation, human_sysprompt_message: settings.human_sysprompt_message, use_ai21_tokenizer: settings.use_ai21_tokenizer, use_google_tokenizer: settings.use_google_tokenizer, @@ -3501,6 +3506,7 @@ function onSettingsPresetChange() { show_external_models: ['#openai_show_external_models', 'show_external_models', true], proxy_password: ['#openai_proxy_password', 'proxy_password', false], assistant_prefill: ['#claude_assistant_prefill', 'assistant_prefill', false], + assistant_impersonation: ['#claude_assistant_impersonation', 'assistant_impersonation', false], human_sysprompt_message: ['#claude_human_sysprompt_textarea', 'human_sysprompt_message', false], use_ai21_tokenizer: ['#use_ai21_tokenizer', 'use_ai21_tokenizer', true], use_google_tokenizer: ['#use_google_tokenizer', 'use_google_tokenizer', true], @@ -3526,6 +3532,11 @@ function onSettingsPresetChange() { preset.names_behavior = character_names_behavior.COMPLETION; } + // Claude: Assistant Impersonation Prefill = Inherit from Assistant Prefill + if (preset.assistant_prefill !== undefined && preset.assistant_impersonation === undefined) { + preset.assistant_impersonation = preset.assistant_prefill; + } + const updateInput = (selector, value) => $(selector).val(value).trigger('input'); const updateCheckbox = (selector, value) => $(selector).prop('checked', value).trigger('input'); @@ -4721,6 +4732,11 @@ $(document).ready(async function () { saveSettingsDebounced(); }); + $('#claude_assistant_impersonation').on('input', function () { + oai_settings.assistant_impersonation = String($(this).val()); + saveSettingsDebounced(); + }); + $('#claude_human_sysprompt_textarea').on('input', function () { oai_settings.human_sysprompt_message = String($('#claude_human_sysprompt_textarea').val()); saveSettingsDebounced(); diff --git a/public/scripts/preset-manager.js b/public/scripts/preset-manager.js index d5c311d16..42cc7585a 100644 --- a/public/scripts/preset-manager.js +++ b/public/scripts/preset-manager.js @@ -114,7 +114,9 @@ class PresetManager { * @returns {any} Preset value */ findPreset(name) { - return $(this.select).find(`option:contains(${name})`).val(); + return $(this.select).find('option').filter(function() { + return $(this).text() === name; + }).val(); } /** diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index d34f5e240..c60b5b975 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -447,8 +447,9 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'unhide', ], helpString: 'Unhides a message from the prompt.', })); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'disable', +SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-disable', callback: disableGroupMemberCallback, + aliases: ['disable', 'disablemember', 'memberdisable'], unnamedArgumentList: [ new SlashCommandArgument( 'member index or name', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], true, @@ -456,7 +457,8 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'disable', ], helpString: 'Disables a group member from being drafted for replies.', })); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'enable', +SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-enable', + aliases: ['enable', 'enablemember', 'memberenable'], callback: enableGroupMemberCallback, unnamedArgumentList: [ new SlashCommandArgument( @@ -465,9 +467,9 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'enable', ], helpString: 'Enables a group member to be drafted for replies.', })); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberadd', +SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-add', callback: addGroupMemberCallback, - aliases: ['addmember'], + aliases: ['addmember', 'memberadd'], unnamedArgumentList: [ new SlashCommandArgument( 'character name', [ARGUMENT_TYPE.STRING], true, @@ -481,15 +483,15 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberadd', Example:
  • -
    /memberadd John Doe
    +
    /member-add John Doe
`, })); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberremove', +SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-remove', callback: removeGroupMemberCallback, - aliases: ['removemember'], + aliases: ['removemember', 'memberremove'], unnamedArgumentList: [ new SlashCommandArgument( 'member index or name', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], true, @@ -503,16 +505,16 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberremove Example:
  • -
    /memberremove 2
    -
    /memberremove John Doe
    +
    /member-remove 2
    +
    /member-remove John Doe

`, })); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberup', +SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-up', callback: moveGroupMemberUpCallback, - aliases: ['upmember'], + aliases: ['upmember', 'memberup'], unnamedArgumentList: [ new SlashCommandArgument( 'member index or name', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], true, @@ -520,9 +522,9 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberup', ], helpString: 'Moves a group member up in the group chat list.', })); -SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberdown', +SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-down', callback: moveGroupMemberDownCallback, - aliases: ['downmember'], + aliases: ['downmember', 'memberdown'], unnamedArgumentList: [ new SlashCommandArgument( 'member index or name', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], true, @@ -2766,10 +2768,12 @@ export async function executeSlashCommandsOnChatInput(text, options = {}) { } } catch (e) { document.querySelector('#form_sheld').classList.add('script_error'); - toastr.error(e.message); result = new SlashCommandClosureResult(); result.isError = true; result.errorMessage = e.message; + if (e.cause !== 'abort') { + toastr.error(e.message); + } } finally { delay(1000).then(()=>clearCommandProgressDebounced()); diff --git a/public/scripts/slash-commands/SlashCommandAutoCompleteNameResult.js b/public/scripts/slash-commands/SlashCommandAutoCompleteNameResult.js index ba163fcd5..8a9c08653 100644 --- a/public/scripts/slash-commands/SlashCommandAutoCompleteNameResult.js +++ b/public/scripts/slash-commands/SlashCommandAutoCompleteNameResult.js @@ -58,6 +58,9 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult { return new RegExp('=(.*)'); } } + if (!Array.isArray(this.executor.command?.namedArgumentList)) { + return null; + } const notProvidedNamedArguments = this.executor.command.namedArgumentList.filter(arg=>!this.executor.namedArgumentList.find(it=>it.name == arg.name)); let name; let value; @@ -130,6 +133,9 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult { } getUnnamedArgumentAt(text, index, isSelect) { + if (!Array.isArray(this.executor.command?.unnamedArgumentList)) { + return null; + } const lastArgIsBlank = this.executor.unnamedArgumentList.slice(-1)[0]?.value == ''; const notProvidedArguments = this.executor.command.unnamedArgumentList.slice(this.executor.unnamedArgumentList.length - (lastArgIsBlank ? 1 : 0)); let value; diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 389e06864..f39e1e926 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -14,10 +14,12 @@ import { // eslint-disable-next-line no-unused-vars import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; -import { groupCandidatesFilter, groups, selected_group } from './group-chats.js'; +import { groupCandidatesFilter, groups, select_group_chats, selected_group } from './group-chats.js'; import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight } from './utils.js'; import { power_user } from './power-user.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, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; export { TAG_FOLDER_TYPES, @@ -357,7 +359,7 @@ function createTagMapFromList(listElement, key) { * If you have an entity, you can get it's key easily via `getTagKeyForEntity(entity)`. * * @param {string} key - The key for which to get tags via the tag map - * @param {boolean} [sort=true] - + * @param {boolean} [sort=true] - Whether the tag list should be sorted * @returns {Tag[]} A list of tags */ function getTagsList(key, sort = true) { @@ -463,35 +465,122 @@ export function getTagKeyForEntityElement(element) { return undefined; } +/** + * Adds a tag to a given entity + * @param {Tag} tag - The tag to add + * @param {string|string[]} entityId - The entity to add this tag to. Has to be the entity key (e.g. `addTagToEntity`). (Also allows multiple entities to be passed in) + * @param {object} [options={}] - Optional arguments + * @param {JQuery|string?} [options.tagListSelector=null] - An optional selector if a specific list should be updated with the new tag too (for example because the add was triggered for that function) + * @param {PrintTagListOptions} [options.tagListOptions] - Optional parameters for printing the tag list. Can be set to be consistent with the expected behavior of tags in the list that was defined before. + * @returns {boolean} Whether at least one tag was added + */ +export function addTagToEntity(tag, entityId, { tagListSelector = null, tagListOptions = {} } = {}) { + let result = false; + // Add tags to the map + if (Array.isArray(entityId)) { + entityId.forEach((id) => result = addTagToMap(tag.id, id) || result); + } else { + result = addTagToMap(tag.id, entityId); + } + + // Save and redraw + 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 + if (tagListSelector) printTagList(tagListSelector, tagListOptions); + const inlineSelector = getInlineListSelector(); + if (inlineSelector) { + printTagList($(inlineSelector), tagListOptions); + } + + return result; +} + +/** + * Removes a tag from a given entity + * @param {Tag} tag - The tag to remove + * @param {string|string[]} entityId - The entity to remove this tag from. Has to be the entity key (e.g. `addTagToEntity`). (Also allows multiple entities to be passed in) + * @param {object} [options={}] - Optional arguments + * @param {JQuery|string?} [options.tagListSelector=null] - An optional selector if a specific list should be updated with the tag removed too (for example because the add was triggered for that function) + * @param {JQuery?} [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. +
+
+ Example: +
    +
  • +
    /tag-add name="Chloe" scenario
    + will add the tag "scenario" to the character named Chloe. +
  • +
+
+ `, + })); + 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}}). +
+
+ Example: +
    +
  • +
    /tag-remove name="Chloe" scenario
    + will remove the tag "scenario" from the character named Chloe. +
  • +
+
+ `, + })); + 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}}). +
+
+ Example: +
    +
  • +
    /tag-exists name="Chloe" scenario
    + will return true if the character named Chloe has the tag "scenario". +
  • +
+
+ `, + })); + 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