Merge pull request #2918 from SillyTavern/char-find-and-sendas-extended Char find and sendas extended

56e9b51f4ebab8b2e86e160b1ede00337f12442e

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
9 files changed, +338 -130Ignore whitespace
public/script.js+2 -1
@@ -230,7 +230,7 @@ import { MacrosParser, evaluateMacros, getLastMessageId } from './scripts/macros
230230import { currentUser, setUserControls } from './scripts/user.js';
231231import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js';
232232import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
233233import { initScrapers, ScraperManager } from './scripts/scrapers.js';
234234import { SlashCommandParser } from './scripts/slash-commands/SlashCommandParser.js';
235235import { SlashCommand } from './scripts/slash-commands/SlashCommand.js';
236236import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './scripts/slash-commands/SlashCommandArgument.js';
@@ -959,6 +959,7 @@ async function firstLoadInit() {
959959 initCfg();
960960 initLogprobs();
961961 initInputMarkdown();
962+ await initScrapers();
962963 doDailyExtensionUpdatesCheck();
963964 await hideLoader();
964965 await fixViewport();
public/scripts/extensions/expressions/index.js+9 -3
@@ -2,7 +2,7 @@ import { callPopup, eventSource, event_types, generateRaw, getRequestHeaders, ma
22import { dragElement, isMobile } from '../../RossAscends-mods.js';
33import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
44import { loadMovingUIState, power_user } from '../../power-user.js';
55import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
66import { hideMutedSprites } from '../../group-chats.js';
77import { isJsonSchemaSupported } from '../../textgen-settings.js';
88import { debounce_timeout } from '../../constants.js';
@@ -2105,14 +2105,20 @@ function migrateSettings() {
21052105 }));
21062106 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21072107 name: 'lastsprite',
21082108 callback: (_, valuename) => lastExpression[String(value).trim()] ?? '',{
2109+ if (typeof name !== 'string') throw new Error('name must be a string');
2110+ const char = findChar({ name: name });
2111+ const sprite = lastExpression[char?.name ?? name] ?? '';
2112+ return sprite;
2113+ },
21092114 returns: 'the last set sprite / expression for the named character.',
21102115 unnamedArgumentList: [
21112116 SlashCommandArgument.fromProps({
21122117 description: 'characterCharacter name - or unique character identifier (avatar key)',
21132118 typeList: [ARGUMENT_TYPE.STRING],
21142119 isRequired: true,
21152120 enumProvider: commonEnumProviders.characters('character'),
2121+ forceEnum: true,
21162122 }),
21172123 ],
21182124 helpString: 'Returns the last set sprite / expression for the named character.',
public/scripts/extensions/gallery/index.js+1 -0
@@ -441,6 +441,7 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
441441 description: 'character name',
442442 typeList: [ARGUMENT_TYPE.STRING],
443443 enumProvider: commonEnumProviders.characters('character'),
444+ forceEnum: true,
444445 }),
445446 SlashCommandNamedArgument.fromProps({
446447 name: 'group',
public/scripts/scrapers.js+16 -7
@@ -13,6 +13,7 @@ import { isValidUrl } from './utils.js';
1313 * @property {string} description
1414 * @property {string} iconClass
1515 * @property {boolean} iconAvailable
16+ * @property {() => Promise<void>} [init=null]
1617 * @property {() => Promise<boolean>} isAvailable
1718 * @property {() => Promise<File[]>} scrape
1819 */
@@ -36,12 +37,16 @@ export class ScraperManager {
3637 * Register a scraper to be used by the Data Bank.
3738 * @param {Scraper} scraper Instance of a scraper to register
3839 */
3940 static async registerDataBankScraper(scraper) {
4041 if (ScraperManager.#scrapers.some(s => s.id === scraper.id)) {
4142 console.warn(`Scraper with ID ${scraper.id} already registered`);
4243 return;
4344 }
4445
46+ if (scraper.init) {
47+ await scraper.init();
48+ }
49+
4550 ScraperManager.#scrapers.push(scraper);
4651 }
4752
@@ -462,7 +467,9 @@ class YouTubeScraper {
462467 this.description = 'Download a transcript from a YouTube video.';
463468 this.iconClass = 'fa-brands fa-youtube';
464469 this.iconAvailable = true;
470+ }
465471
472+ async init() {
466473 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
467474 name: 'yt-script',
468475 callback: async (args, url) => {
@@ -564,9 +571,11 @@ class YouTubeScraper {
564571 }
565572}
566573
567-ScraperManager.registerDataBankScraper(new FileScraper());
574+export async function initScrapers() {
568575 await ScraperManager.registerDataBankScraper(new NotepadFileScraper());
569576 await ScraperManager.registerDataBankScraper(new WebScraperNotepad());
570577 await ScraperManager.registerDataBankScraper(new MediaWikiScraperWebScraper());
571578 await ScraperManager.registerDataBankScraper(new FandomScraperMediaWikiScraper());
572579 await ScraperManager.registerDataBankScraper(new YouTubeScraperFandomScraper());
580+ await ScraperManager.registerDataBankScraper(new YouTubeScraper());
581+}
public/scripts/slash-commands.js+212 -106
@@ -55,7 +55,7 @@ import { autoSelectPersona, retriggerFirstMessageOnEmptyChat, setPersonaLockStat
5555import { addEphemeralStoppingString, chat_styles, flushEphemeralStoppingStrings, power_user } from './power-user.js';
5656import { SERVER_INPUTS, textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
5757import { decodeTextTokens, getAvailableTokenizers, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, selectTokenizer } from './tokenizers.js';
5858import { debounce, delay, equalsIgnoreCaseAndAccents, findChar, getCharIndex, isFalseBoolean, isTrueBoolean, showFontAwesomePicker, stringToRange, trimToEndSentence, trimToStartSentence, waitUntilCondition } from './utils.js';
5959import { registerVariableCommands, resolveVariable } from './variables.js';
6060import { background_settings } from './backgrounds.js';
6161import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
@@ -68,7 +68,6 @@ import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashComma
6868import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
6969import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
7070import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
71-import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';
7271import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
7372import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
7473export {
@@ -174,20 +173,96 @@ export function initDefaultSlashCommands() {
174173 `,
175174 }));
176175 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
176+ name: 'char-find',
177+ aliases: ['findchar'],
178+ callback: (args, name) => {
179+ if (typeof name !== 'string') throw new Error('name must be a string');
180+ if (args.preferCurrent instanceof SlashCommandClosure || Array.isArray(args.preferCurrent)) throw new Error('preferCurrent cannot be a closure or array');
181+ if (args.quiet instanceof SlashCommandClosure || Array.isArray(args.quiet)) throw new Error('quiet cannot be a closure or array');
182+
183+ const char = findChar({ name: name, filteredByTags: validateArrayArgString(args.tag, 'tag'), preferCurrentChar: !isFalseBoolean(args.preferCurrent), quiet: isTrueBoolean(args.quiet) });
184+ return char?.avatar ?? '';
185+ },
186+ returns: 'the avatar key (unique identifier) of the character',
187+ namedArgumentList: [
188+ SlashCommandNamedArgument.fromProps({
189+ name: 'tag',
190+ description: 'Supply one or more tags to filter down to the correct character for the provided name, if multiple characters have the same name.',
191+ typeList: [ARGUMENT_TYPE.STRING],
192+ enumProvider: commonEnumProviders.tags('assigned'),
193+ acceptsMultiple: true,
194+ }),
195+ SlashCommandNamedArgument.fromProps({
196+ name: 'preferCurrent',
197+ description: 'Prefer current character or characters in a group, if multiple characters match',
198+ typeList: [ARGUMENT_TYPE.BOOLEAN],
199+ defaultValue: 'true',
200+ }),
201+ SlashCommandNamedArgument.fromProps({
202+ name: 'quiet',
203+ description: 'Do not show warning if multiple charactrers are found',
204+ typeList: [ARGUMENT_TYPE.BOOLEAN],
205+ defaultValue: 'false',
206+ enumProvider: commonEnumProviders.boolean('trueFalse'),
207+ }),
208+ ],
209+ unnamedArgumentList: [
210+ SlashCommandArgument.fromProps({
211+ description: 'Character name - or unique character identifier (avatar key)',
212+ typeList: [ARGUMENT_TYPE.STRING],
213+ enumProvider: commonEnumProviders.characters('character'),
214+ forceEnum: false,
215+ }),
216+ ],
217+ helpString: `
218+ <div>
219+ Searches for a character and returns its avatar key.
220+ </div>
221+ <div>
222+ This can be used to choose the correct character for something like <code>/sendas</code> or other commands in need of a character name
223+ if you have multiple characters with the same name.
224+ </div>
225+ <div>
226+ <strong>Example:</strong>
227+ <ul>
228+ <li>
229+ <pre><code>/char-find name="Chloe"</code></pre>
230+ Returns the avatar key for "Chloe".
231+ </li>
232+ <li>
233+ <pre><code>/search name="Chloe" tag="friend"</code></pre>
234+ Returns the avatar key for the character "Chloe" that is tagged with "friend".
235+ This is useful if you for example have multiple characters named "Chloe", and the others are "foe", "goddess", or anything else,
236+ so you can actually select the character you are looking for.
237+ </li>
238+ </ul>
239+ </div>
240+ `,
241+ }));
242+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
177243 name: 'sendas',
178244 callback: sendMessageAs,
179245 namedArgumentList: [
180246 SlashCommandNamedArgument.fromProps({
181247 name: 'name',
182248 description: 'Character name - or unique character identifier (avatar key)',
183249 typeList: [ARGUMENT_TYPE.STRING],
184250 isRequired: true,
185251 enumProvider: commonEnumProviders.characters('character'),
186252 forceEnum: false,
187253 }),
188254 new SlashCommandNamedArgument.fromProps({
189- 'compact', 'Use compact layout', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false',
255+ name: 'avatar',
190- ),
256+ description: 'Character avatar override (Can be either avatar key or just the character name to pull the avatar from)',
257+ typeList: [ARGUMENT_TYPE.STRING],
258+ enumProvider: commonEnumProviders.characters('character'),
259+ }),
260+ SlashCommandNamedArgument.fromProps({
261+ name: 'compact',
262+ description: 'Use compact layout',
263+ typeList: [ARGUMENT_TYPE.BOOLEAN],
264+ defaultValue: 'false',
265+ }),
191266 SlashCommandNamedArgument.fromProps({
192267 name: 'at',
193268 description: 'position to insert the message (index-based, corresponding to message id). If not set, the message will be inserted at the end of the chat.\nNegative values are accepted and will work similarly to how \'depth\' usually works. For example, -1 will insert the message right before the last message in chat.',
@@ -211,6 +286,10 @@ export function initDefaultSlashCommands() {
211286 <pre><code>/sendas name="Chloe" Hello, guys!</code></pre>
212287 will send "Hello, guys!" from "Chloe".
213288 </li>
289+ <li>
290+ <pre><code>/sendas name="Chloe" avatar="BigBadBoss" Hehehe, I am the big bad evil, fear me.</code></pre>
291+ will send a message as the character "Chloe", but utilizing the avatar from a character named "BigBadBoss".
292+ </li>
214293 </ul>
215294 </div>
216295 <div>
@@ -381,12 +460,14 @@ export function initDefaultSlashCommands() {
381460 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
382461 name: 'go',
383462 callback: goToCharacterCallback,
463+ returns: 'The character/group name',
384464 unnamedArgumentList: [
385465 SlashCommandArgument.fromProps({
386- description: 'name',
466+ description: 'Character name - or unique character identifier (avatar key)',
387467 typeList: [ARGUMENT_TYPE.STRING],
388468 isRequired: true,
389469 enumProvider: commonEnumProviders.characters('all'),
470+ forceEnum: true,
390471 }),
391472 ],
392473 helpString: 'Opens up a chat with the character or group by its name',
@@ -432,7 +513,7 @@ export function initDefaultSlashCommands() {
432513 namedArgumentList: [
433514 SlashCommandNamedArgument.fromProps({
434515 name: 'name',
435516 description: 'characterCharacter name - or unique character identifier (avatar key)',
436517 typeList: [ARGUMENT_TYPE.STRING],
437518 isRequired: true,
438519 enumProvider: commonEnumProviders.characters('character'),
@@ -451,7 +532,7 @@ export function initDefaultSlashCommands() {
451532 namedArgumentList: [],
452533 unnamedArgumentList: [
453534 SlashCommandArgument.fromProps({
454- description: 'name',
535+ description: 'Character name - or unique character identifier (avatar key)',
455536 typeList: [ARGUMENT_TYPE.STRING],
456537 isRequired: true,
457538 enumProvider: commonEnumProviders.characters('character'),
@@ -618,7 +699,7 @@ export function initDefaultSlashCommands() {
618699 aliases: ['addmember', 'memberadd'],
619700 unnamedArgumentList: [
620701 SlashCommandArgument.fromProps({
621702 description: 'characterCharacter name - or unique character identifier (avatar key)',
622703 typeList: [ARGUMENT_TYPE.STRING],
623704 isRequired: true,
624705 enumProvider: () => selected_group ? commonEnumProviders.characters('character')() : [],
@@ -856,7 +937,7 @@ export function initDefaultSlashCommands() {
856937 ),
857938 SlashCommandNamedArgument.fromProps({
858939 name: 'name',
859940 description: 'in-prompt character name for instruct mode (or unique character identifier (avatar key), which will be used as name)',
860941 typeList: [ARGUMENT_TYPE.STRING],
861942 defaultValue: 'System',
862943 enumProvider: () => [...commonEnumProviders.characters('character')(), new SlashCommandEnumValue('System', null, enumTypes.enum, enumIcons.assistant)],
@@ -2293,7 +2374,8 @@ async function generateCallback(args, value) {
22932374
22942375 setEphemeralStopStrings(resolveVariable(args?.stop));
22952376 const name = args?.name;
22962377 const resultchar = await generateQuietPromptfindChar(value, quietToLoud, false,{ '',name: name, length});
2378+ const result = await generateQuietPrompt(value, quietToLoud, false, '', char?.name ?? name, length);
22972379 return result;
22982380 } catch (err) {
22992381 console.error('Error on /gen generation', err);
@@ -2481,26 +2563,22 @@ async function askCharacter(args, text) {
24812563 return '';
24822564 }
24832565
2484- let name = '';
2566+ if (!args.name) {
2485-
2567+ toastr.warning('You must specify a name of the character to ask.');
2486- if (args?.name) {
2568+ return '';
2487- name = args.name.trim();
2488-
2489- if (!name) {
2490- toastr.warning('You must specify a name of the character to ask.');
2491- return '';
2492- }
24932569 }
24942570
24952571 const prevChId = this_chid;
24962572
24972573 // Find the character
2498- const chId = characters.findIndex((e) => e.name === name || e.avatar === name);
2574+ const character = findChar({ name: args?.name });
2499- if (!characters[chId] || chId === -1) {
2575+ if (!character) {
25002576 toastr.error('Character not found.');
25012577 return '';
25022578 }
25032579
2580+ const chId = getCharIndex(character);
2581+
25042582 if (text) {
25052583 const mesText = getRegexedString(text.trim(), regex_placement.SLASH_COMMAND);
25062584 // Sending a message implicitly saves the chat, so this needs to be done before changing the character
@@ -2511,19 +2589,9 @@ async function askCharacter(args, text) {
25112589 // Override character and send a user message
25122590 setCharacterId(String(chId));
25132591
2514- const character = characters[chId];
2592+ const { name, force_avatar, original_avatar } = getNameAndAvatarForMessage(character, args?.name);
2515- let force_avatar, original_avatar;
2516-
2517- if (character && character.avatar !== 'none') {
2518- force_avatar = getThumbnailUrl('avatar', character.avatar);
2519- original_avatar = character.avatar;
2520- }
2521- else {
2522- force_avatar = default_avatar;
2523- original_avatar = default_avatar;
2524- }
25252593
25262594 setCharacterName(character.name);
25272595
25282596 const restoreCharacter = () => {
25292597 if (String(this_chid) !== String(chId)) {
@@ -2541,7 +2609,7 @@ async function askCharacter(args, text) {
25412609 // Only force the new avatar if the character name is the same
25422610 // This skips if an error was fired
25432611 const lastMessage = chat[chat.length - 1];
25442612 if (lastMessage && lastMessage?.name === character.name) {
25452613 lastMessage.force_avatar = force_avatar;
25462614 lastMessage.original_avatar = original_avatar;
25472615 }
@@ -2552,7 +2620,7 @@ async function askCharacter(args, text) {
25522620 // Run generate and restore previous character
25532621 try {
25542622 eventSource.once(event_types.MESSAGE_RECEIVED, restoreCharacter);
25552623 toastr.info(`Asking ${character.name} something...`);
25562624 askResult = await Generate('ask_command');
25572625 } catch (error) {
25582626 restoreCharacter();
@@ -2746,26 +2814,23 @@ async function removeGroupMemberCallback(_, arg) {
27462814 return '';
27472815}
27482816
27492817async function addGroupMemberCallback(_, argname) {
27502818 if (!selected_group) {
27512819 toastr.warning('Cannot run /memberadd command outside of a group chat.');
27522820 return '';
27532821 }
27542822
27552823 if (!argname) {
27562824 console.warn('WARN: No argument provided for /memberadd command');
27572825 return '';
27582826 }
27592827
2760- arg = arg.trim();
2828+ const character = findChar({ name: name, preferCurrentChar: false });
2761- const chid = findCharacterIndex(arg);
2829+ if (!character) {
2762-
2830+ console.warn(`WARN: No character found for argument ${name}`);
2763- if (chid === -1) {
2764- console.warn(`WARN: No character found for argument ${arg}`);
27652831 return '';
27662832 }
27672833
2768- const character = characters[chid];
27692834 const group = groups.find(x => x.id === selected_group);
27702835
27712836 if (!group || !Array.isArray(group.members)) {
@@ -2834,7 +2899,7 @@ function findPersonaByName(name) {
28342899 }
28352900
28362901 for (const persona of Object.entries(power_user.personas)) {
28372902 if (equalsIgnoreCaseAndAccents(persona[1].toLowerCase() ===, name.toLowerCase()) {
28382903 return persona[0];
28392904 }
28402905 }
@@ -2877,7 +2942,9 @@ async function deleteMessagesByNameCallback(_, name) {
28772942 return;
28782943 }
28792944
2880- name = name.trim();
2945+ // Search for a matching character to get the real name, or take the name provided
2946+ const character = findChar({ name: name });
2947+ name = character?.name || name;
28812948
28822949 const messagesToDelete = [];
28832950 chat.forEach((value) => {
@@ -2906,60 +2973,34 @@ async function deleteMessagesByNameCallback(_, name) {
29062973 return '';
29072974}
29082975
2909-function findCharacterIndex(name) {
2910- const matchTypes = [
2911- (a, b) => a === b,
2912- (a, b) => a.startsWith(b),
2913- (a, b) => a.includes(b),
2914- ];
2915-
2916- const exactAvatarMatch = characters.findIndex(x => x.avatar === name);
2917-
2918- if (exactAvatarMatch !== -1) {
2919- return exactAvatarMatch;
2920- }
2921-
2922- for (const matchType of matchTypes) {
2923- const index = characters.findIndex(x => matchType(x.name.toLowerCase(), name.toLowerCase()));
2924- if (index !== -1) {
2925- return index;
2926- }
2927- }
2928-
2929- return -1;
2930-}
2931-
29322976async function goToCharacterCallback(_, name) {
29332977 if (!name) {
29342978 console.warn('WARN: No character name provided for /go command');
29352979 return;
29362980 }
29372981
2938- name = name.trim();
2982+ const character = findChar({ name: name });
2939- const characterIndex = findCharacterIndex(name);
2983+ if (character) {
2940-
2984+ const chid = getCharIndex(character);
2941- if (characterIndex !== -1) {
2985+ await openChat(new String(chid));
2942- await openChat(new String(characterIndex));
2986+ setActiveCharacter(character.avatar);
2943- setActiveCharacter(characters[characterIndex]?.avatar);
29442987 setActiveGroup(null);
29452988 return characters[characterIndex]?character.name;
29462989 } else {
29472990 const group = groups.find(it => equalsIgnoreCaseAndAccents(it.name.toLowerCase() ==, name.toLowerCase());
29482991 if (group) {
29492992 await openGroupById(group.id);
29502993 setActiveCharacter(null);
29512994 setActiveGroup(group.id);
29522995 return group.name;
2953- } else {
2954- console.warn(`No matches found for name "${name}"`);
2955- return '';
2956- }
29572996 }
2997+ console.warn(`No matches found for name "${name}"`);
2998+ return '';
29582999}
29593000
29603001async function openChat(idchid) {
29613002 resetSelectedGroup();
29623003 setCharacterId(idchid);
29633004 await delay(1);
29643005 await reloadCurrentChat();
29653006}
@@ -3105,6 +3146,79 @@ async function setNarratorName(_, text) {
31053146 return '';
31063147}
31073148
3149+/**
3150+ * Checks if an argument is a string array (or undefined), and if not, throws an error
3151+ * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined} arg The named argument to check
3152+ * @param {string} name The name of the argument for the error message
3153+ * @param {object} [options={}] - The optional arguments
3154+ * @param {boolean} [options.allowUndefined=false] - Whether the argument can be undefined
3155+ * @throws {Error} If the argument is not an array
3156+ * @returns {string[]}
3157+ */
3158+export function validateArrayArgString(arg, name, { allowUndefined = true } = {}) {
3159+ if (arg === undefined) {
3160+ if (allowUndefined) return undefined;
3161+ throw new Error(`Argument "${name}" is undefined, but must be a string array`);
3162+ }
3163+ if (!Array.isArray(arg)) throw new Error(`Argument "${name}" must be an array`);
3164+ if (!arg.every(x => typeof x === 'string')) throw new Error(`Argument "${name}" must be an array of strings`);
3165+ return arg;
3166+}
3167+
3168+/**
3169+ * Checks if an argument is a string or closure array (or undefined), and if not, throws an error
3170+ * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined} arg The named argument to check
3171+ * @param {string} name The name of the argument for the error message
3172+ * @param {object} [options={}] - The optional arguments
3173+ * @param {boolean} [options.allowUndefined=false] - Whether the argument can be undefined
3174+ * @throws {Error} If the argument is not an array of strings or closures
3175+ * @returns {(string|SlashCommandClosure)[]}
3176+ */
3177+export function validateArrayArg(arg, name, { allowUndefined = true } = {}) {
3178+ if (arg === undefined) {
3179+ if (allowUndefined) return [];
3180+ throw new Error(`Argument "${name}" is undefined, but must be an array of strings or closures`);
3181+ }
3182+ if (!Array.isArray(arg)) throw new Error(`Argument "${name}" must be an array`);
3183+ if (!arg.every(x => typeof x === 'string' || x instanceof SlashCommandClosure)) throw new Error(`Argument "${name}" must be an array of strings or closures`);
3184+ return arg;
3185+}
3186+
3187+
3188+/**
3189+ * Retrieves the name and avatar information for a message
3190+ *
3191+ * The name of the character will always have precendence over the one given as argument. If you want to specify a different name for the message,
3192+ * explicitly implement this in the code using this.
3193+ *
3194+ * @param {object?} character - The character object to get the avatar data for
3195+ * @param {string?} name - The name to get the avatar data for
3196+ * @returns {{name: string, force_avatar: string, original_avatar: string}} An object containing the name for the message, forced avatar URL, and original avatar
3197+ */
3198+export function getNameAndAvatarForMessage(character, name = null) {
3199+ const isNeutralCharacter = !character && name2 === neutralCharacterName && name === neutralCharacterName;
3200+ const currentChar = characters[this_chid];
3201+
3202+ let force_avatar, original_avatar;
3203+ if (character?.avatar === currentChar?.avatar || isNeutralCharacter) {
3204+ // If the targeted character is the currently selected one in a solo chat, we don't need to force any avatars
3205+ }
3206+ else if (character && character.avatar !== 'none') {
3207+ force_avatar = getThumbnailUrl('avatar', character.avatar);
3208+ original_avatar = character.avatar;
3209+ }
3210+ else {
3211+ force_avatar = default_avatar;
3212+ original_avatar = default_avatar;
3213+ }
3214+
3215+ return {
3216+ name: character?.name || name,
3217+ force_avatar: force_avatar,
3218+ original_avatar: original_avatar,
3219+ };
3220+}
3221+
31083222export async function sendMessageAs(args, text) {
31093223 if (!text) {
31103224 return '';
@@ -3143,26 +3257,18 @@ export async function sendMessageAs(args, text) {
31433257 const isSystem = bias && !removeMacros(mesText).length;
31443258 const compact = isTrueBoolean(args?.compact);
31453259
3146- const character = characters.find(x => x.avatar === name) ?? characters.find(x => x.name === name);
3260+ const character = findChar({ name: name });
3147- let force_avatar, original_avatar;
3148-
3149- const chatCharacter = this_chid !== undefined ? characters[this_chid] : null;
3150- const isNeutralCharacter = !chatCharacter && name2 === neutralCharacterName && name === neutralCharacterName;
31513261
3152- if (chatCharacter === character || isNeutralCharacter) {
3262+ const avatarCharacter = args.avatar ? findChar({ name: args.avatar }) : character;
3153- // If the targeted character is the currently selected one in a solo chat, we don't need to force any avatars
3263+ if (args.avatar && !avatarCharacter) {
3154- }
3264+ toastr.warning(`Character for avatar ${args.avatar} not found`);
3155- else if (character && character.avatar !== 'none') {
3265+ return '';
3156- force_avatar = getThumbnailUrl('avatar', character.avatar);
3157- original_avatar = character.avatar;
3158- }
3159- else {
3160- force_avatar = default_avatar;
3161- original_avatar = default_avatar;
31623266 }
31633267
3268+ const { name: avatarCharName, force_avatar, original_avatar } = getNameAndAvatarForMessage(avatarCharacter, name);
3269+
31643270 const message = {
3165- name: name,
3271+ name: character?.name || name || avatarCharName,
31663272 is_user: false,
31673273 is_system: isSystem,
31683274 send_date: getMessageTimeStamp(),
public/scripts/slash-commands/SlashCommand.js+2 -2
@@ -15,13 +15,13 @@ import { SlashCommandScope } from './SlashCommandScope.js';
1515 * _abortController:SlashCommandAbortController,
1616 * _debugController:SlashCommandDebugController,
1717 * _hasUnnamedArgument:boolean,
1818 * [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined,
1919 * }} NamedArguments
2020 */
2121
2222/**
2323 * Alternative object for local JSDocs, where you don't need existing pipe, scope, etc. arguments
2424 * @typedef {{[id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined}} NamedArgumentsCapture
2525 */
2626
2727/**
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+14 -2
@@ -2,7 +2,7 @@ import { chat_metadata, characters, substituteParams, chat, extension_prompt_rol
22import { extension_settings } from '../extensions.js';
33import { getGroupMembers, groups } from '../group-chats.js';
44import { power_user } from '../power-user.js';
55import { searchCharByName, getTagsList, tags, tag_map } from '../tags.js';
66import { world_names } from '../world-info.js';
77import { SlashCommandClosure } from './SlashCommandClosure.js';
88import { SlashCommandEnumValue, enumTypes } from './SlashCommandEnumValue.js';
@@ -182,6 +182,18 @@ export const commonEnumProviders = {
182182 personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),
183183
184184 /**
185+ * All possible tags, or only those that have been assigned
186+ *
187+ * @param {('all' | 'assigned')} [mode='all'] - Which types of tags to show
188+ * @returns {() => SlashCommandEnumValue[]}
189+ */
190+ tags: (mode = 'all') => () => {
191+ let assignedTags = mode === 'assigned' ? new Set(Object.values(tag_map).flat()) : new Set();
192+ return tags.filter(tag => mode === 'all' || (mode === 'assigned' && assignedTags.has(tag.id)))
193+ .map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag));
194+ },
195+
196+ /**
185197 * All possible tags for a given char/group entity
186198 *
187199 * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show
@@ -193,7 +205,7 @@ export const commonEnumProviders = {
193205 if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');
194206 const key = searchCharByName(substituteParams(charName), { suppressLogging: true });
195207 const assigned = key ? getTagsList(key) : [];
196208 return tags.filter(it => !key || mode === 'all' || mode === 'existing' && assigned.includes(it) || mode === 'not-existing' && !assigned.includes(it))
197209 .map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag));
198210 },
199211
public/scripts/tags.js+8 -8
@@ -15,7 +15,7 @@ import {
1515import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';
1616
1717import { groupCandidatesFilter, groups, selected_group } from './group-chats.js';
1818import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js';
1919import { power_user } from './power-user.js';
2020import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
2121import { SlashCommand } from './slash-commands/SlashCommand.js';
@@ -50,7 +50,6 @@ export {
5050 removeTagFromMap,
5151};
5252
53-/** @typedef {import('../scripts/popup.js').Popup} Popup */
5453/** @typedef {import('../script.js').Character} Character */
5554
5655const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter';
@@ -507,7 +506,7 @@ export function getTagKeyForEntityElement(element) {
507506 */
508507export function searchCharByName(charName, { suppressLogging = false } = {}) {
509508 const entity = charName
510509 ? (characters.findfindChar(x =>{ x.name ===: charName }) || groups.find(x => equalsIgnoreCaseAndAccents(x.name ==, charName)))
511510 : (selected_group ? groups.find(x => x.id == selected_group) : characters[this_chid]);
512511 const key = getTagKeyForEntity(entity);
513512 if (!key) {
@@ -1861,8 +1860,9 @@ function registerTagsSlashCommands() {
18611860 return String(result);
18621861 },
18631862 namedArgumentList: [
18641863 SlashCommandNamedArgument.fromProps({ name: 'name',
18651864 descriptionname: 'Character name',
1865+ description: 'Character name - or unique character identifier (avatar key)',
18661866 typeList: [ARGUMENT_TYPE.STRING],
18671867 defaultValue: '{{char}}',
18681868 enumProvider: commonEnumProviders.characters(),
@@ -1907,7 +1907,7 @@ function registerTagsSlashCommands() {
19071907 },
19081908 namedArgumentList: [
19091909 SlashCommandNamedArgument.fromProps({ name: 'name',
19101910 description: 'Character name - or unique character identifier (avatar key)',
19111911 typeList: [ARGUMENT_TYPE.STRING],
19121912 defaultValue: '{{char}}',
19131913 enumProvider: commonEnumProviders.characters(),
@@ -1950,7 +1950,7 @@ function registerTagsSlashCommands() {
19501950 namedArgumentList: [
19511951 SlashCommandNamedArgument.fromProps({
19521952 name: 'name',
19531953 description: 'Character name - or unique character identifier (avatar key)',
19541954 typeList: [ARGUMENT_TYPE.STRING],
19551955 defaultValue: '{{char}}',
19561956 enumProvider: commonEnumProviders.characters(),
@@ -1993,7 +1993,7 @@ function registerTagsSlashCommands() {
19931993 namedArgumentList: [
19941994 SlashCommandNamedArgument.fromProps({
19951995 name: 'name',
19961996 description: 'Character name - or unique character identifier (avatar key)',
19971997 typeList: [ARGUMENT_TYPE.STRING],
19981998 defaultValue: '{{char}}',
19991999 enumProvider: commonEnumProviders.characters(),
public/scripts/utils.js+74 -1
@@ -1,10 +1,12 @@
11import { getContext } from './extensions.js';
22import { characters, getRequestHeaders, this_chid } from '../script.js';
33import { isMobile } from './RossAscends-mods.js';
44import { collapseNewlines } from './power-user.js';
55import { debounce_timeout } from './constants.js';
66import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
77import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
8+import { getTagsList } from './tags.js';
9+import { groups, selected_group } from './group-chats.js';
810
911/**
1012 * Pagination status string template.
@@ -2110,3 +2112,74 @@ export async function showFontAwesomePicker(customList = null) {
21102112 }
21112113 return null;
21122114}
2115+
2116+/**
2117+ * Finds a character by name, with optional filtering and precedence for avatars
2118+ * @param {object} [options={}] - The options for the search
2119+ * @param {string?} [options.name=null] - The name to search for
2120+ * @param {boolean} [options.allowAvatar=true] - Whether to allow searching by avatar
2121+ * @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive
2122+ * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by
2123+ * @param {boolean} [options.preferCurrentChar=true] - Whether to prefer the current character(s)
2124+ * @param {boolean} [options.quiet=false] - Whether to suppress warnings
2125+ * @returns {any?} - The found character or null if not found
2126+ */
2127+export function findChar({ name = null, allowAvatar = true, insensitive = true, filteredByTags = null, preferCurrentChar = true, quiet = false } = {}) {
2128+ const matches = (char) => !name || (allowAvatar && char.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name);
2129+
2130+ // Filter characters by tags if provided
2131+ let filteredCharacters = characters;
2132+ if (filteredByTags) {
2133+ filteredCharacters = characters.filter(char => {
2134+ const charTags = getTagsList(char.avatar, false);
2135+ return filteredByTags.every(tagName => charTags.some(x => x.name == tagName));
2136+ });
2137+ }
2138+
2139+ // Get the current character(s)
2140+ /** @type {any[]} */
2141+ const currentChars = selected_group ? groups.find(group => group.id === selected_group)?.members.map(member => filteredCharacters.find(char => char.avatar === member))
2142+ : filteredCharacters.filter(char => characters[this_chid]?.avatar === char.avatar);
2143+
2144+ // If we have a current char and prefer it, return that if it matches
2145+ if (preferCurrentChar) {
2146+ const preferredCharSearch = currentChars.filter(matches);
2147+ if (preferredCharSearch.length > 1) {
2148+ if (!quiet) toastr.warning('Multiple characters found for given conditions.');
2149+ else console.warn('Multiple characters found for given conditions. Returning the first match.');
2150+ }
2151+ if (preferredCharSearch.length) {
2152+ return preferredCharSearch[0];
2153+ }
2154+ }
2155+
2156+ // If allowAvatar is true, search by avatar first
2157+ if (allowAvatar && name) {
2158+ const characterByAvatar = filteredCharacters.find(char => char.avatar === name);
2159+ if (characterByAvatar) {
2160+ return characterByAvatar;
2161+ }
2162+ }
2163+
2164+ // Search for matching characters by name
2165+ const matchingCharacters = name ? filteredCharacters.filter(matches) : filteredCharacters;
2166+ if (matchingCharacters.length > 1) {
2167+ if (!quiet) toastr.warning('Multiple characters found for given conditions.');
2168+ else console.warn('Multiple characters found for given conditions. Returning the first match.');
2169+ }
2170+
2171+ return matchingCharacters[0] || null;
2172+}
2173+
2174+/**
2175+ * Gets the index of a character based on the character object
2176+ * @param {object} char - The character object to find the index for
2177+ * @throws {Error} If the character is not found
2178+ * @returns {number} The index of the character in the characters array
2179+ */
2180+export function getCharIndex(char) {
2181+ if (!char) throw new Error('Character is undefined');
2182+ const index = characters.findIndex(c => c.avatar === char.avatar);
2183+ if (index === -1) throw new Error(`Character not found: ${char.avatar}`);
2184+ return index;
2185+}